Source code for sandman.sandman

"""Sandman REST API creator for Flask and SQLAlchemy"""

from flask import (
    jsonify,
    request,
    current_app,
    Response,
    render_template,
    make_response)
from sqlalchemy.exc import IntegrityError
from . import app
from .decorators import etag, no_cache
from .exception import InvalidAPIUsage
from .model.models import Model
from .model.utils import _get_session

JSON, HTML = range(2)
JSON_CONTENT_TYPES = set(['application/json'])
HTML_CONTENT_TYPES = set(['text/html', 'application/x-www-form-urlencoded'])
ALL_CONTENT_TYPES = set(['*/*'])
ACCEPTABLE_CONTENT_TYPES = (
    JSON_CONTENT_TYPES |
    HTML_CONTENT_TYPES |
    ALL_CONTENT_TYPES)

FORWARDED_EXCEPTION_MESSAGE = 'Request could not be completed. Exception: [{}]'
FORBIDDEN_EXCEPTION_MESSAGE = """Method [{}] not acceptable for resource \
type [{}].  Acceptable methods: [{}]"""
UNSUPPORTED_CONTENT_TYPE_MESSAGE = 'Content-type [{types}] not supported.'

def _perform_database_action(action, *args):
    """Call session.*action* with the given *args*.

    Will later be used to abstract away database backend.
    """
    session = _get_session()
    getattr(session, action)(*args)
    session.commit()


def _get_acceptable_response_type():
    """Return the mimetype for this request."""
    if ('Accept' not in request.headers or request.headers['Accept'] in
            ALL_CONTENT_TYPES):
        return JSON
    acceptable_content_types = set(
        request.headers['ACCEPT'].strip().split(','))
    if acceptable_content_types & HTML_CONTENT_TYPES:
        return HTML
    elif acceptable_content_types & JSON_CONTENT_TYPES:
        return JSON
    else:
        # HTTP 406 Not Acceptable
        raise InvalidAPIUsage(406)


[docs]@app.errorhandler(InvalidAPIUsage) def handle_exception(error): """Return a response with the appropriate status code, message, and content type when an ``InvalidAPIUsage`` exception is raised.""" try: if _get_acceptable_response_type() == JSON: response = jsonify(error.to_dict()) response.status_code = error.code return response else: return error.abort() except InvalidAPIUsage: # In addition to the original exception, we don't support the content # type in the request's 'Accept' header, which is a more important # error, so return that instead of what was originally raised. response = jsonify(error.to_dict()) response.status_code = 415 return response
def _single_attribute_json_response(name, value): """Return the json representation of a single attribute of a resource. :param string name: name of the attribute :param string value: string value of the attribute :rtype: :class:`flask.Response` """ return jsonify({name: str(value)}) def _single_resource_json_response(resource, depth=0): """Return the JSON representation of *resource*. :param resource: :class:`sandman.model.Model` to render :type resource: :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ links = resource.links() response = jsonify(**resource.as_dict(depth)) response.headers['Link'] = '' for link in links: response.headers['Link'] += '<{}>; rel="{}",'.format( link['uri'], link['rel']) response.headers['Link'] = response.headers['Link'][:-1] return response def _single_attribute_html_response(resource, name, value): """Return the json representation of a single attribute of a resource. :param :class:`sandman.model.Model` resource: resource for attribute :param string name: name of the attribute :param string value: string value of the attribute :rtype: :class:`flask.Response` """ return make_response(render_template( 'attribute.html', resource=resource, name=name, value=value)) def _single_resource_html_response(resource): """Return the HTML representation of *resource*. :param resource: :class:`sandman.model.Model` to render :type resource: :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ tablename = resource.__tablename__ resource.pk = getattr(resource, resource.primary_key()) resource.attributes = resource.as_dict() return make_response(render_template( 'resource.html', resource=resource, tablename=tablename)) def _collection_json_response(cls, resources, start, stop, depth=0): """Return the JSON representation of the collection *resources*. :param list resources: list of :class:`sandman.model.Model`s to render :rtype: :class:`flask.Response` """ top_level_json_name = None if cls.__top_level_json_name__ is not None: top_level_json_name = cls.__top_level_json_name__ else: top_level_json_name = 'resources' result_list = [] for resource in resources: result_list.append(resource.as_dict(depth)) payload = {} if start is not None: payload[top_level_json_name] = result_list[start:stop] else: payload[top_level_json_name] = result_list return jsonify(payload) def _collection_html_response(resources, start=0, stop=20): """Return the HTML representation of the collection *resources*. :param list resources: list of :class:`sandman.model.Model`s to render :rtype: :class:`flask.Response` """ return make_response(render_template( 'collection.html', resources=resources[start:stop])) def _validate(cls, method, resource=None): """Return ``True`` if the the given *cls* supports the HTTP *method* found on the incoming HTTP request. :param cls: class associated with the request's endpoint :type cls: :class:`sandman.model.Model` instance :param string method: HTTP method of incoming request :param resource: *cls* instance associated with the request :type resource: :class:`sandman.model.Model` or list of :class:`sandman.model.Model` or None :rtype: bool """ if method not in cls.__methods__: raise InvalidAPIUsage(403, FORBIDDEN_EXCEPTION_MESSAGE.format( method, cls.endpoint(), cls.__methods__)) class_validator_name = 'validate_' + method if hasattr(cls, class_validator_name): class_validator = getattr(cls, class_validator_name) if not class_validator(resource): raise InvalidAPIUsage(403)
[docs]def get_resource_data(incoming_request): """Return the data from the incoming *request* based on the Content-type.""" content_type = incoming_request.headers['Content-type'].split(';')[0] if ('Content-type' not in incoming_request.headers or content_type in JSON_CONTENT_TYPES): return incoming_request.json elif content_type in HTML_CONTENT_TYPES: if not incoming_request.form: raise InvalidAPIUsage(400) return incoming_request.form else: # HTTP 415: Unsupported Media Type raise InvalidAPIUsage( 415, UNSUPPORTED_CONTENT_TYPE_MESSAGE.format( types=incoming_request.headers['Content-type']))
[docs]def endpoint_class(collection): """Return the :class:`sandman.model.Model` associated with the endpoint *collection*. :param string collection: a :class:`sandman.model.Model` endpoint :rtype: :class:`sandman.model.Model` """ with app.app_context(): try: cls = current_app.class_references[collection] except KeyError: raise InvalidAPIUsage(404) return cls
[docs]def retrieve_collection(collection, query_arguments=None): """Return the resources in *collection*, possibly filtered by a series of values to use in a 'where' clause search. :param string collection: a :class:`sandman.model.Model` endpoint :param dict query_arguments: a list of filter query arguments :rtype: class:`sandman.model.Model` """ session = _get_session() cls = endpoint_class(collection) if query_arguments: filters = [] order = [] limit = None for key, value in query_arguments.items(): if key == 'page': continue if value.startswith('%'): filters.append(getattr(cls, key).like(str(value), escape='/')) elif key == 'sort': order.append(getattr(cls, value)) elif key == 'limit': limit = value elif key: filters.append(getattr(cls, key) == value) resources = session.query(cls).filter(*filters).order_by( *order).limit(limit) else: resources = session.query(cls).all() return resources
[docs]def retrieve_resource(collection, key): """Return the resource in *collection* identified by key *key*. :param string collection: a :class:`sandman.model.Model` endpoint :param string key: primary key of resource :rtype: class:`sandman.model.Model` """ session = _get_session() cls = endpoint_class(collection) resource = session.query(cls).get(key) if resource is None: raise InvalidAPIUsage(404) return resource
[docs]def resource_created_response(resource): """Return HTTP response with status code *201*, signaling a created *resource* :param resource: resource created as a result of current request :type resource: :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ if _get_acceptable_response_type() == JSON: response = _single_resource_json_response(resource) else: response = _single_resource_html_response(resource) response.status_code = 201 response.headers['Location'] = 'http://localhost:5000/{}'.format( resource.resource_uri()) return response
[docs]def collection_response(cls, resources, start=None, stop=None): """Return a response for the *resources* of the appropriate content type. :param resources: resources to be returned in request :type resource: list of :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ if _get_acceptable_response_type() == JSON: return _collection_json_response(cls, resources, start, stop) else: return _collection_html_response(resources, start, stop)
[docs]def resource_response(resource, depth=0): """Return a response for the *resource* of the appropriate content type. :param resource: resource to be returned in request :type resource: :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ if _get_acceptable_response_type() == JSON: depth = 0 if 'expand' in request.args: depth = 1 return _single_resource_json_response(resource, depth) else: return _single_resource_html_response(resource)
[docs]def attribute_response(resource, name, value): """Return a response for the *resource* of the appropriate content type. :param resource: resource to be returned in request :type resource: :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ if _get_acceptable_response_type() == JSON: return _single_attribute_json_response(name, value) else: return _single_attribute_html_response(resource, name, value)
[docs]@no_cache def no_content_response(): """Return the appropriate *Response* with status code *204*, signaling a completed action which does not require data in the response body :rtype: :class:`flask.Response` """ response = Response() response.status_code = 204 return response
[docs]def update_resource(resource, incoming_request): """Replace the contents of a resource with *data* and return an appropriate *Response*. :param resource: :class:`sandman.model.Model` to be updated :param data: New values for the fields in *resource* """ resource.from_dict(get_resource_data(incoming_request)) _perform_database_action('merge', resource) return no_content_response()
[docs]@app.route('/<collection>/<key>', methods=['PATCH']) def patch_resource(collection, key): """"Upsert" a resource identified by the given key and return the appropriate *Response*. If no resource currently exists at `/<collection>/<key>`, create it with *key* as its primary key and return a :func:`resource_created_response`. If a resource *does* exist at `/<collection>/<key>`, update it with the data sent in the request and return a :func:`no_content_response`. Note: HTTP `PATCH` (and, thus, :func:`patch_resource`) is idempotent :param string collection: a :class:`sandman.model.Model` endpoint :param string key: the primary key for the :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ cls = endpoint_class(collection) try: resource = retrieve_resource(collection, key) except InvalidAPIUsage: resource = None _validate(cls, request.method, resource) if resource is None: resource = cls() resource.from_dict(get_resource_data(request)) setattr(resource, resource.primary_key(), key) _perform_database_action('add', resource) return resource_created_response(resource) else: return update_resource(resource, request)
[docs]@app.route('/<collection>/<key>', methods=['PUT']) def put_resource(collection, key): """Replace the resource identified by the given key and return the appropriate response. :param string collection: a :class:`sandman.model.Model` endpoint :rtype: :class:`flask.Response` """ resource = retrieve_resource(collection, key) _validate(endpoint_class(collection), request.method, resource) resource.replace(get_resource_data(request)) try: _perform_database_action('add', resource) except IntegrityError as exception: raise InvalidAPIUsage(422, FORWARDED_EXCEPTION_MESSAGE.format( exception)) return no_content_response()
[docs]@app.route('/<collection>', methods=['POST']) def post_resource(collection): """Return the appropriate *Response* based on adding a new resource to *collection*. :param string collection: a :class:`sandman.model.Model` endpoint :rtype: :class:`flask.Response` """ cls = endpoint_class(collection) resource = cls() resource.from_dict(get_resource_data(request)) _validate(cls, request.method, resource) _perform_database_action('add', resource) return resource_created_response(resource)
[docs]@app.route('/<collection>/<key>', methods=['DELETE']) def delete_resource(collection, key): """Return the appropriate *Response* for deleting an existing resource in *collection*. :param string collection: a :class:`sandman.model.Model` endpoint :param string key: the primary key for the :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ cls = endpoint_class(collection) resource = cls() resource = retrieve_resource(collection, key) _validate(cls, request.method, resource) try: _perform_database_action('delete', resource) except IntegrityError as exception: raise InvalidAPIUsage(422, FORWARDED_EXCEPTION_MESSAGE.format( exception)) return no_content_response()
[docs]@app.route('/<collection>/<key>', methods=['GET']) @etag def get_resource(collection, key): """Return the appropriate *Response* for retrieving a single resource. :param string collection: a :class:`sandman.model.Model` endpoint :param string key: the primary key for the :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ resource = retrieve_resource(collection, key) _validate(endpoint_class(collection), request.method, resource) return resource_response(resource)
[docs]@app.route('/<collection>/<key>/<attribute>', methods=['GET']) @etag def get_resource_attribute(collection, key, attribute): """Return the appropriate *Response* for retrieving an attribute of a single resource. :param string collection: a :class:`sandman.model.Model` endpoint :param string key: the primary key for the :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ resource = retrieve_resource(collection, key) _validate(endpoint_class(collection), request.method, resource) value = getattr(resource, attribute) if isinstance(value, Model): return resource_response(value) else: return attribute_response(resource, attribute, value)
[docs]@app.route('/<collection>', methods=['GET']) @etag def get_collection(collection): """Return the appropriate *Response* for retrieving a collection of resources. :param string collection: a :class:`sandman.model.Model` endpoint :param string key: the primary key for the :class:`sandman.model.Model` :rtype: :class:`flask.Response` """ cls = endpoint_class(collection) resources = retrieve_collection(collection, request.args) _validate(cls, request.method, resources) start = stop = None if request.args and 'page' in request.args: page = int(request.args['page']) results_per_page = app.config.get('RESULTS_PER_PAGE', 20) start, stop = page * results_per_page, (page + 1) * results_per_page return collection_response(cls, resources, start, stop)
[docs]@app.route('/', methods=['GET']) @etag def index(): """Return information about each type of resource and how it can be accessed.""" classes = [] with app.app_context(): classes = set(current_app.class_references.values()) if _get_acceptable_response_type() == JSON: meta_data = {} for cls in classes: meta_data[cls.endpoint()] = { 'link': '/' + cls.endpoint(), 'meta': '/' + cls.endpoint() + '/meta' } return jsonify(meta_data) else: return render_template('index.html', classes=classes)
[docs]@app.route('/<collection>/meta', methods=['GET']) @etag def get_meta(collection): """Return the meta-description of a given resource. :param collection: The collection to get meta-info for """ cls = endpoint_class(collection) description = cls.meta() return jsonify(description)