"""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)