From 362f6cf7ba201d8dc362a331d4f151159ba83d4a Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Mon, 9 Feb 2015 19:26:50 -0500 Subject: [PATCH] Makes API compliant with JSON API specification. Previously, the behavior of Flask-Restless was a bit arbitrary. Now we force it to comply with a concrete (though still changing) specification, which can be found at http://jsonapi.org/. This is a (severely) backwards-incompatible change, as it changes which API endpoints are exposed and the format of requests and responses. This change also moves JSON API compliance tests to a convenient distinct test module, `tests.test_jsonapi.py`, so that compliance with the specification can be easily verified. These tests correspond to version 1.0rc2 of the JSON API specification, which can be found in commit json-api/json-api@af5dfcc50ac3838601cf3f26a8e89a5778529c89. This change fixes (or at least makes it much easier to fix or much easier to mark as "won't fix") quite a few issues, including but not limited to - #87 - #153 - #168 - #193 - #208 - #211 - #213 - #243 - #252 - #253 - #258 - #261 - #262 - #303 - #394 --- docs/api.rst | 4 + docs/customizing.rst | 15 + flask_restless/__init__.py | 3 + flask_restless/helpers.py | 607 +++++++--- flask_restless/manager.py | 194 ++- flask_restless/search.py | 135 ++- flask_restless/views.py | 1656 ++++++++++++++++++++------ requirements.txt | 2 +- setup.py | 2 +- tests/helpers.py | 495 ++++---- tests/test_creating.py | 463 ++++++++ tests/test_deleting.py | 233 ++++ tests/test_fetching.py | 408 +++++++ tests/test_filtering.py | 546 +++++++++ tests/test_functions.py | 133 +++ tests/test_jsonapi.py | 2267 ++++++++++++++++++++++++++++++++++++ tests/test_updating.py | 960 +++++++++++++++ tests/test_views.py | 1421 +++++----------------- 18 files changed, 7563 insertions(+), 1981 deletions(-) create mode 100644 tests/test_creating.py create mode 100644 tests/test_deleting.py create mode 100644 tests/test_fetching.py create mode 100644 tests/test_filtering.py create mode 100644 tests/test_functions.py create mode 100644 tests/test_jsonapi.py create mode 100644 tests/test_updating.py diff --git a/docs/api.rst b/docs/api.rst index dd0afa2f..7b1dba92 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -14,6 +14,10 @@ in Flask-Restless. .. automethod:: create_api_blueprint +.. autofunction:: collection_name(model, _apimanager=None) + +.. autofunction:: model_for(collection_name, _apimanager=None) + .. autofunction:: url_for(model, instid=None, relationname=None, relationinstid=None, _apimanager=None, **kw) .. autoclass:: ProcessingException diff --git a/docs/customizing.rst b/docs/customizing.rst index 650cfcee..d47b486c 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -113,6 +113,21 @@ method:: Then the API will be exposed at ``/api/people`` instead of ``/api/person``. +.. note:: + + According to the `JSON API standard`_, + + .. blockquote:: + + Note: This spec is agnostic about inflection rules, so the value of type + can be either plural or singular. However, the same value should be used + consistently throughout an implementation. + + It's up to you to make sure your collection names are either all plural or + all singular! + +.. _JSON API standard: http://jsonapi.org/format/#document-structure-resource-types + Specifying one of many primary keys ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/flask_restless/__init__.py b/flask_restless/__init__.py index 364a6115..a14d1ff0 100644 --- a/flask_restless/__init__.py +++ b/flask_restless/__init__.py @@ -20,7 +20,10 @@ __version__ = '0.17.1-dev' # make the following names available as part of the public API +from .helpers import collection_name +from .helpers import model_for from .helpers import url_for from .manager import APIManager from .manager import IllegalArgumentError +from .views import CONTENT_TYPE from .views import ProcessingException diff --git a/flask_restless/helpers.py b/flask_restless/helpers.py index 15e33ba8..6dc93092 100644 --- a/flask_restless/helpers.py +++ b/flask_restless/helpers.py @@ -11,12 +11,15 @@ """ import datetime import inspect +from urllib.parse import urljoin import uuid from dateutil.parser import parse as parse_datetime +from flask import request from sqlalchemy import Date from sqlalchemy import DateTime from sqlalchemy import Interval +from sqlalchemy import Time from sqlalchemy.exc import NoInspectionAvailable from sqlalchemy.exc import OperationalError from sqlalchemy.ext.associationproxy import AssociationProxy @@ -30,6 +33,7 @@ from sqlalchemy.sql import func from sqlalchemy.sql.expression import ColumnElement from sqlalchemy.inspection import inspect as sqlalchemy_inspect +from werkzeug.urls import url_quote_plus #: Names of attributes which should definitely not be considered relations when #: dynamically computing a list of relations of a SQLAlchemy model. @@ -157,42 +161,18 @@ def has_field(model, fieldname): def get_field_type(model, fieldname): - """Helper which returns the SQLAlchemy type of the field. - - """ + """Helper which returns the SQLAlchemy type of the field.""" field = getattr(model, fieldname) if isinstance(field, ColumnElement): - fieldtype = field.type - else: - if isinstance(field, AssociationProxy): - field = field.remote_attr - if hasattr(field, 'property'): - prop = field.property - if isinstance(prop, RelProperty): - return None - fieldtype = prop.columns[0].type - else: + return field.type + if isinstance(field, AssociationProxy): + field = field.remote_attr + if hasattr(field, 'property'): + prop = field.property + if isinstance(prop, RelProperty): return None - return fieldtype - - -def is_date_field(model, fieldname): - """Returns ``True`` if and only if the field of `model` with the specified - name corresponds to either a :class:`datetime.date` object or a - :class:`datetime.datetime` object. - - """ - fieldtype = get_field_type(model, fieldname) - return isinstance(fieldtype, Date) or isinstance(fieldtype, DateTime) - - -def is_interval_field(model, fieldname): - """Returns ``True`` if and only if the field of `model` with the specified - name corresponds to a :class:`datetime.timedelta` object. - - """ - fieldtype = get_field_type(model, fieldname) - return isinstance(fieldtype, Interval) + return prop.columns[0].type + return None def assign_attributes(model, **kwargs): @@ -233,6 +213,26 @@ def primary_key_name(model_or_instance): return 'id' if 'id' in pk_names else pk_names[0] +def primary_key_value(instance, as_string=False): + """Returns the value of the primary key field of the specified `instance` + of a SQLAlchemy model. + + This is a convenience function for:: + + getattr(instance, primary_key_name(instance)) + + If `as_string` is ``True``, try to coerce the return value to a string. + + """ + result = getattr(instance, primary_key_name(instance)) + if not as_string: + return result + try: + return str(result) + except UnicodeEncodeError: + return url_quote_plus(result.encode('utf-8')) + + def is_like_list(instance, relation): """Returns ``True`` if and only if the relation of `instance` whose name is `relation` is list-like. @@ -267,12 +267,8 @@ def is_mapped_class(cls): return False -# This code was adapted from :meth:`elixir.entity.Entity.to_dict` and -# http://stackoverflow.com/q/1958219/108197. -def to_dict(instance, deep=None, exclude=None, include=None, - exclude_relations=None, include_relations=None, - include_methods=None): - """Returns a dictionary representing the fields of the specified `instance` +def to_dict(instance, only=None): + """Returns a dictionary representing the fields of the specified instance of a SQLAlchemy model. The returned dictionary is suitable as an argument to @@ -280,70 +276,59 @@ def to_dict(instance, deep=None, exclude=None, include=None, objects are converted to string representations, so no special JSON encoder behavior is required. - `deep` is a dictionary containing a mapping from a relation name (for a - relation of `instance`) to either a list or a dictionary. This is a - recursive structure which represents the `deep` argument when calling - :func:`!_to_dict` on related instances. When an empty list is encountered, - :func:`!_to_dict` returns a list of the string representations of the - related instances. - - If either `include` or `exclude` is not ``None``, exactly one of them must - be specified. If both are not ``None``, then this function will raise a - :exc:`ValueError`. `exclude` must be a list of strings specifying the - columns which will *not* be present in the returned dictionary - representation of the object (in other words, it is a - blacklist). Similarly, `include` specifies the only columns which will be - present in the returned dictionary (in other words, it is a whitelist). - - .. note:: + If `only` is a list, only the fields and relationships whose names appear + as strings in `only` will appear in the resulting dictionary. The only + exception is that the keys ``'id'`` and ``'type'`` will always appear, + regardless of whether they appear in `only`. - If `include` is an iterable of length zero (like the empty tuple or the - empty list), then the returned dictionary will be empty. If `include` is - ``None``, then the returned dictionary will include all columns not - excluded by `exclude`. + Since this function creates absolute URLs to resources linked to the given + instance, it must be called within a `Flask request context`_. - `include_relations` is a dictionary mapping strings representing relation - fields on the specified `instance` to a list of strings representing the - names of fields on the related model which should be included in the - returned dictionary; `exclude_relations` is similar. - - `include_methods` is a list mapping strings to method names which will - be called and their return values added to the returned dictionary. + .. _Flask request context: http://flask.pocoo.org/docs/0.10/reqcontext/ """ - if (exclude is not None or exclude_relations is not None) and \ - (include is not None or include_relations is not None): - raise ValueError('Cannot specify both include and exclude.') - # create a list of names of columns, including hybrid properties - instance_type = type(instance) - columns = [] + model = type(instance) try: - inspected_instance = sqlalchemy_inspect(instance_type) + inspected_instance = sqlalchemy_inspect(model) column_attrs = inspected_instance.column_attrs.keys() descriptors = inspected_instance.all_orm_descriptors.items() hybrid_columns = [k for k, d in descriptors - if d.extension_type == hybrid.HYBRID_PROPERTY - and not (deep and k in deep)] + if d.extension_type == hybrid.HYBRID_PROPERTY] + #and not (deep and k in deep)] columns = column_attrs + hybrid_columns except NoInspectionAvailable: return instance - # filter the columns based on exclude and include values - if exclude is not None: - columns = (c for c in columns if c not in exclude) - elif include is not None: - columns = (c for c in columns if c in include) - # create a dictionary mapping column name to value - result = dict((col, getattr(instance, col)) for col in columns - if not (col.startswith('__') or col in COLUMN_BLACKLIST)) - # add any included methods - if include_methods is not None: - for method in include_methods: - if '.' not in method: - value = getattr(instance, method) - # Allow properties and static attributes in include_methods - if callable(value): - value = value() - result[method] = value + # Exclude column names that are blacklisted. + columns = (c for c in columns + if not c.startswith('__') and c not in COLUMN_BLACKLIST) + # If `only` is a list, only include those columns that are in the list. + if only is not None: + columns = (c for c in columns if c in only) + # Create a dictionary mapping attribute name to attribute value for this + # particular instance. + result = {column: getattr(instance, column) for column in columns} + # Call any functions that appear in the result. + result = {k: (v() if callable(v) else v) for k, v in result.items()} + # Add the resource type to the result dictionary. + result['type'] = collection_name(model) + # Add the self link unless it has been explicitly excluded. + if only is None or 'self' in only: + instance_id = primary_key_value(instance) + url = urljoin(request.url_root, url_for(model, instance_id)) + result['links'] = dict(self=url) + # # add any included methods + # if include_methods is not None: + # for method in include_methods: + # if '.' not in method: + # value = getattr(instance, method) + # # Allow properties and static attributes in include_methods + # if callable(value): + # value = value() + # result[method] = value + + # TODO Should the responsibility for serializing date and uuid objects move + # outside of this function? I think so. + # # Check for objects in the dictionary that may not be serializable by # default. Convert datetime objects to ISO 8601 format, convert UUID # objects to hexadecimal strings, etc. @@ -354,45 +339,228 @@ def to_dict(instance, deep=None, exclude=None, include=None, result[key] = str(value) elif key not in column_attrs and is_mapped_class(type(value)): result[key] = to_dict(value) - # recursively call _to_dict on each of the `deep` relations - deep = deep or {} - for relation, rdeep in deep.items(): - # Get the related value so we can see if it is None, a list, a query - # (as specified by a dynamic relationship loader), or an actual - # instance of a model. - relatedvalue = getattr(instance, relation) - if relatedvalue is None: - result[relation] = None - continue - # Determine the included and excluded fields for the related model. - newexclude = None - newinclude = None - if exclude_relations is not None and relation in exclude_relations: - newexclude = exclude_relations[relation] - elif (include_relations is not None and - relation in include_relations): - newinclude = include_relations[relation] - # Determine the included methods for the related model. - newmethods = None - if include_methods is not None: - newmethods = [method.split('.', 1)[1] for method in include_methods - if method.split('.', 1)[0] == relation] - if is_like_list(instance, relation): - result[relation] = [to_dict(inst, rdeep, exclude=newexclude, - include=newinclude, - include_methods=newmethods) - for inst in relatedvalue] - continue - # If the related value is dynamically loaded, resolve the query to get - # the single instance. - if isinstance(relatedvalue, Query): - relatedvalue = relatedvalue.one() - result[relation] = to_dict(relatedvalue, rdeep, exclude=newexclude, - include=newinclude, - include_methods=newmethods) + # If the primary key is not named "id", we'll duplicate the primary key + # under the "id" key. + pk_name = primary_key_name(model) + if pk_name != 'id': + result['id'] = result[pk_name] + # TODO Same problem as above. + # + # In order to comply with the JSON API standard, primary keys must be + # returned to the client as strings, so we convert it here. + if 'id' in result: + result['id'] = str(result['id']) + # If there are relations to convert to dictionary form, put them into a + # special `links` key as required by JSON API. + relations = get_relations(model) + # Only consider those relations listed in `only`. + if only is not None: + relations = [r for r in relations if r in only] + if relations: + # The links mapping may already exist if a self link was added above. + if 'links' not in result: + result['links'] = {} + for relation in relations: + # Create the common elements in the link object: the `self` and + # `resource` links. + result['links'][relation] = {} + link = result['links'][relation] + link['self'] = url_for(model, primary_key_value(instance), + relation, relationship=True) + link['resource'] = url_for(model, primary_key_value(instance), + relation) + # Get the related value so we can see if it is a to-many + # relationship or a to-one relationship. + related_value = getattr(instance, relation) + # If the related value is list-like, it represents a to-many + # relationship. + if is_like_list(instance, relation): + # If this is a heterogeneous to-many relationship, have a type + # and an ID for each member. + if is_heterogeneous(instance, relation): + link['data'] = [dict(type=collection_name(get_model(inst)), + id=str(primary_key_value(inst))) + for inst in related_value] + continue + # If this is a homogeneous to-many relationship, have a single + # type and a list of IDs. + related_model = get_related_model(model, relation) + link['type'] = collection_name(related_model) + link['ids'] = [str(primary_key_value(inst)) + for inst in related_value] + continue + # At this point, we know we have a to-one relationship. + related_model = get_related_model(model, relation) + link['type'] = collection_name(related_model) + # If the related value is None, that means we have an empty to-one + # relationship. + if related_value is None: + link['id'] = None + continue + # If the related value is dynamically loaded, resolve the query to + # get the single instance in the to-one relationship. + if isinstance(related_value, Query): + related_value = related_value.one() + link['id'] = str(primary_key_value(related_value)) return result +# TODO This function is not implemented. +def is_heterogeneous(instance, relation): + """Returns ``True`` if and only if the to-many relation with the specified + name is a heterogeneous to-many relation on the given instance of a + SQLAlchemy model. + + """ + return False + + +# # This code was adapted from :meth:`elixir.entity.Entity.to_dict` and +# # http://stackoverflow.com/q/1958219/108197. +# def to_dict(instance, type_, deep=None, exclude=None, include=None, +# exclude_relations=None, include_relations=None, +# include_methods=None): +# """Returns a dictionary representing the fields of the specified `instance` +# of a SQLAlchemy model. + +# The returned dictionary is suitable as an argument to +# :func:`flask.jsonify`; :class:`datetime.date` and :class:`uuid.UUID` +# objects are converted to string representations, so no special JSON encoder +# behavior is required. + +# `type_` is a string representing the resource type of `instance`, as +# required by JSON API. + +# `deep` is a dictionary containing a mapping from a relation name (for a +# relation of `instance`) to either a list or a dictionary. This is a +# recursive structure which represents the `deep` argument when calling +# :func:`!_to_dict` on related instances. When an empty list is encountered, +# :func:`!_to_dict` returns a list of the string representations of the +# related instances. + +# If either `include` or `exclude` is not ``None``, exactly one of them must +# be specified. If both are not ``None``, then this function will raise a +# :exc:`ValueError`. `exclude` must be a list of strings specifying the +# columns which will *not* be present in the returned dictionary +# representation of the object (in other words, it is a +# blacklist). Similarly, `include` specifies the only columns which will be +# present in the returned dictionary (in other words, it is a whitelist). + +# .. note:: + +# If `include` is an iterable of length zero (like the empty tuple or the +# empty list), then the returned dictionary will be empty. If `include` is +# ``None``, then the returned dictionary will include all columns not +# excluded by `exclude`. + +# `include_relations` is a dictionary mapping strings representing relation +# fields on the specified `instance` to a list of strings representing the +# names of fields on the related model which should be included in the +# returned dictionary; `exclude_relations` is similar. + +# `include_methods` is a list mapping strings to method names which will +# be called and their return values added to the returned dictionary. + +# """ +# if (exclude is not None or exclude_relations is not None) and \ +# (include is not None or include_relations is not None): +# raise ValueError('Cannot specify both include and exclude.') +# # create a list of names of columns, including hybrid properties +# instance_type = type(instance) +# columns = [] +# try: +# inspected_instance = sqlalchemy_inspect(instance_type) +# column_attrs = inspected_instance.column_attrs.keys() +# descriptors = inspected_instance.all_orm_descriptors.items() +# hybrid_columns = [k for k, d in descriptors +# if d.extension_type == hybrid.HYBRID_PROPERTY +# and not (deep and k in deep)] +# columns = column_attrs + hybrid_columns +# except NoInspectionAvailable: +# return instance +# # filter the columns based on exclude and include values +# if exclude is not None: +# columns = (c for c in columns if c not in exclude) +# elif include is not None: +# columns = (c for c in columns if c in include) +# # create a dictionary mapping column name to value +# result = dict((col, getattr(instance, col)) for col in columns +# if not (col.startswith('__') or col in COLUMN_BLACKLIST)) +# # add any included methods +# if include_methods is not None: +# for method in include_methods: +# if '.' not in method: +# value = getattr(instance, method) +# # Allow properties and static attributes in include_methods +# if callable(value): +# value = value() +# result[method] = value +# # Check for objects in the dictionary that may not be serializable by +# # default. Convert datetime objects to ISO 8601 format, convert UUID +# # objects to hexadecimal strings, etc. +# for key, value in result.items(): +# if isinstance(value, (datetime.date, datetime.time)): +# result[key] = value.isoformat() +# elif isinstance(value, uuid.UUID): +# result[key] = str(value) +# elif key not in column_attrs and is_mapped_class(type(value)): +# result[key] = to_dict(value) +# # In order to comply with the JSON API standard, primary keys must be +# # returned to the client as strings, so we convert it here. +# if 'id' in result: +# result['id'] = str(result['id']) +# # recursively call _to_dict on each of the `deep` relations +# deep = deep or {} +# # If there are relations to convert to dictionary form, put them into a +# # special `links` key as required by JSON API. +# if deep: +# result['links'] = {} +# for relation, rdeep in deep.items(): +# # Get the related value so we can see if it is None, a list, a query +# # (as specified by a dynamic relationship loader), or an actual +# # instance of a model. +# relatedvalue = getattr(instance, relation) +# if relatedvalue is None: +# result['links'][relation] = None +# continue + +# # # Determine the included and excluded fields for the related model. +# # newexclude = None +# # newinclude = None +# # if exclude_relations is not None and relation in exclude_relations: +# # newexclude = exclude_relations[relation] +# # elif (include_relations is not None and +# # relation in include_relations): +# # newinclude = include_relations[relation] +# # # Determine the included methods for the related model. +# # newmethods = None +# # if include_methods is not None: +# # newmethods = [method.split('.', 1)[1] for method in include_methods +# # if method.split('.', 1)[0] == relation] + +# # If the related value is list-like, return a list of primary key +# # values. +# if is_like_list(instance, relation): +# result['links'][relation] = [str(primary_key_value(inst)) +# for inst in relatedvalue] +# continue +# # If the related value is dynamically loaded, resolve the query to get +# # the single instance. +# if isinstance(relatedvalue, Query): +# relatedvalue = relatedvalue.one() +# model = get_model(instance) +# instid = primary_key_value(instance) +# # TODO The resource URL needs to be /api/1/computers instead of +# # /api/1/links/computers, but I don't know how to do that cleanly. +# related_resource_url = url_for(model, instid, relation) +# relationship_url = url_for(model, instid, relation) +# result['links'][relation] = dict(resource=related_resource_url, +# self=relationship_url) +# # Add the resource type to the result dictionary. +# result['type'] = type_ +# return result + + def evaluate_functions(session, model, functions): """Executes each of the SQLAlchemy functions specified in ``functions``, a list of dictionaries of the form described below, on the given model and @@ -542,7 +710,35 @@ def get_or_create(session, model, attrs): return model(**attrs) -def strings_to_dates(model, dictionary): +def string_to_datetime(model, fieldname, value): + # If this is a date, time or datetime field, parse it and convert it to + # the appropriate type. + field_type = get_field_type(model, fieldname) + if isinstance(field_type, (Date, Time, DateTime)): + # If the string is empty, no datetime can be inferred from it. + if value.strip() == '': + return None + # If the string is a string indicating that the value of should be the + # current datetime on the server, get the current datetime that way. + if value in CURRENT_TIME_MARKERS: + return getattr(func, value.lower())() + value_as_datetime = parse_datetime(value) + # If the attribute on the model needs to be a Date or Time object as + # opposed to a DateTime object, just get the date component of the + # datetime. + if isinstance(field_type, Date): + return value_as_datetime.date() + if isinstance(field_type, Time): + return value_as_datetime.time() + return value_as_datetime + # If this is an Interval field, convert the integer value to a timedelta. + if isinstance(field_type, Interval) and isinstance(value, int): + return datetime.timedelta(seconds=value) + # In any other case, simply copy the value unchanged. + return value + + +def strings_to_datetimes(model, dictionary): """Returns a new dictionary with all the mappings of `dictionary` but with date strings and intervals mapped to :class:`datetime.datetime` or :class:`datetime.timedelta` objects. @@ -558,28 +754,8 @@ def strings_to_dates(model, dictionary): This function outputs a new dictionary; it does not modify the argument. """ - result = {} - for fieldname, value in dictionary.items(): - if is_date_field(model, fieldname) and value is not None: - if value.strip() == '': - result[fieldname] = None - elif value in CURRENT_TIME_MARKERS: - result[fieldname] = getattr(func, value.lower())() - else: - value_as_datetime = parse_datetime(value) - result[fieldname] = value_as_datetime - # If the attribute on the model needs to be a Date object as - # opposed to a DateTime object, just get the date component of - # the datetime. - fieldtype = get_field_type(model, fieldname) - if isinstance(fieldtype, Date): - result[fieldname] = value_as_datetime.date() - elif (is_interval_field(model, fieldname) and value is not None - and isinstance(value, int)): - result[fieldname] = datetime.timedelta(seconds=value) - else: - result[fieldname] = value - return result + return {k: string_to_datetime(v) for k, v in dictionary.items() + if k not in ('type', 'links')} def count(session, query): @@ -597,6 +773,11 @@ def count(session, query): return num_results +def get_model(instance): + """Returns the model class of which the specified object is an instance.""" + return type(instance) + + # This code comes from , which is # licensed under the Creative Commons Attribution-ShareAlike License version # 3.0 Unported. @@ -626,13 +807,58 @@ class Singleton(_Singleton('SingletonMeta', (object,), {})): pass -class UrlFinder(Singleton): - """The singleton class that backs the :func:`url_for` function.""" +class KnowsAPIManagers: def __init__(self): - #: A global list of created :class:`APIManager` objects. - self.created_managers = [] + self.created_managers = set() + + def register(self, apimanager): + self.created_managers.add(apimanager) + + +class ModelFinder(KnowsAPIManagers, Singleton): + """The singleton class that backs the :func:`model_for` function.""" + + def __call__(self, collection_name, _apimanager=None, **kw): + if _apimanager is not None: + # This may raise ValueError. + return _apimanager.model_for(collection_name, **kw) + for manager in self.created_managers: + try: + return self(collection_name, _apimanager=manager, **kw) + except ValueError: + pass + message = ('No model with collection name {0} is known to any' + ' APIManager objects; maybe you have not set the' + ' `collection_name` keyword argument when calling' + ' `APIManager.create_api()`?').format(collection_name) + raise ValueError(message) + + +class CollectionNameFinder(KnowsAPIManagers, Singleton): + """The singleton class that backs the :func:`collection_name` function.""" + + def __call__(self, model, _apimanager=None, **kw): + if _apimanager is not None: + if model not in _apimanager.created_apis_for: + message = ('APIManager {0} has not created an API for model ' + ' {1}').format(_apimanager, model) + raise ValueError(message) + return _apimanager.collection_name(model, **kw) + for manager in self.created_managers: + try: + return self(model, _apimanager=manager, **kw) + except ValueError: + pass + message = ('Model {0} is not known to any APIManager' + ' objects; maybe you have not called' + ' APIManager.create_api() for this model.').format(model) + raise ValueError(message) + + +class UrlFinder(KnowsAPIManagers, Singleton): + """The singleton class that backs the :func:`url_for` function.""" def __call__(self, model, instid=None, relationname=None, relationinstid=None, _apimanager=None, **kw): @@ -652,7 +878,8 @@ def __call__(self, model, instid=None, relationname=None, except ValueError: pass message = ('Model {0} is not known to any APIManager' - ' objects').format(model) + ' objects; maybe you have not called' + ' APIManager.create_api() for this model.').format(model) raise ValueError(message) @@ -684,4 +911,72 @@ def __call__(self, model, instid=None, relationname=None, #: #: The remaining keyword arguments, `kw`, are passed directly on to #: :func:`flask.url_for`. +#: +#: Since this function creates absolute URLs to resources linked to the given +#: instance, it must be called within a `Flask request context`_. +#: +#: .. _Flask request context: http://flask.pocoo.org/docs/0.10/reqcontext/ +#: url_for = UrlFinder() + +#: Returns the collection name for the specified model, as specified by the +#: ``collection_name`` keyword argument to :meth:`APIManager.create_api` when +#: it was previously invoked on the model. +#: +#: `model` is a SQLAlchemy model class. This should be a model on which +#: :meth:`APIManager.create_api_blueprint` (or :meth:`APIManager.create_api`) +#: has been invoked previously. If no API has been created for it, this +#: function raises a `ValueError`. +#: +#: If `_apimanager` is not ``None``, it must be an instance of +#: :class:`APIManager`. Restrict our search for endpoints exposing `model` to +#: only endpoints created by the specified :class:`APIManager` instance. +#: +#: For example, suppose you have a model class ``Person`` and have created the +#: appropriate Flask application and SQLAlchemy session:: +#: +#: >>> from mymodels import Person +#: >>> manager = APIManager(app, session=session) +#: >>> manager.create_api(Person, collection_name='people') +#: >>> collection_name(Person) +#: 'people' +#: +#: This function is the inverse of :func:`model_for`:: +#: +#: >>> manager.collection_name(manager.model_for('people')) +#: 'people' +#: >>> manager.model_for(manager.collection_name(Person)) +#: +#: +collection_name = CollectionNameFinder() + +#: Returns the model corresponding to the given collection name, as specified +#: by the ``collection_name`` keyword argument to :meth:`APIManager.create_api` +#: when it was previously invoked on the model. +#: +#: `collection_name` is a string corresponding to the "type" of a model. This +#: should be a model on which :meth:`APIManager.create_api_blueprint` (or +#: :meth:`APIManager.create_api`) has been invoked previously. If no API has +#: been created for it, this function raises a `ValueError`. +#: +#: If `_apimanager` is not ``None``, it must be an instance of +#: :class:`APIManager`. Restrict our search for endpoints exposing `model` to +#: only endpoints created by the specified :class:`APIManager` instance. +#: +#: For example, suppose you have a model class ``Person`` and have created the +#: appropriate Flask application and SQLAlchemy session:: +#: +#: >>> from mymodels import Person +#: >>> manager = APIManager(app, session=session) +#: >>> manager.create_api(Person, collection_name='people') +#: >>> model_for('people') +#: +#: +#: This function is the inverse of :func:`collection_name`:: +#: +#: >>> manager.collection_name(manager.model_for('people')) +#: 'people' +#: >>> manager.model_for(manager.collection_name(Person)) +#: +#: +model_for = ModelFinder() diff --git a/flask_restless/manager.py b/flask_restless/manager.py index 651ce409..83f69188 100644 --- a/flask_restless/manager.py +++ b/flask_restless/manager.py @@ -14,14 +14,19 @@ """ from collections import defaultdict from collections import namedtuple +from urllib.parse import urljoin import flask +from flask import request from flask import Blueprint from .helpers import primary_key_name +from .helpers import collection_name +from .helpers import model_for from .helpers import url_for from .views import API from .views import FunctionAPI +from .views import RelationshipAPI #: The set of methods which are allowed by default when creating an API READONLY_METHODS = frozenset(('GET', )) @@ -37,9 +42,6 @@ 'universal_preprocessors', 'universal_postprocessors']) -#: A global list of created :class:`APIManager` objects. -created_managers = [] - #: A tuple that stores information about a created API. #: #: The first element, `collection_name`, is the name by which a collection of @@ -131,9 +133,14 @@ def __init__(self, app=None, **kw): #: the corresponding collection names for those models. self.created_apis_for = {} - # Stash this instance so that it can be examined later by other - # functions in this module. - url_for.created_managers.append(self) + # Stash this instance so that it can be examined later by the global + # `url_for`, `model_for`, and `collection_name` functions. + # + # TODO This is a bit of poor code style because it requires the + # APIManager to know about these global functions that use it. + url_for.register(self) + model_for.register(self) + collection_name.register(self) self.flask_sqlalchemy_db = kw.pop('flask_sqlalchemy_db', None) self.session = kw.pop('session', None) @@ -182,32 +189,59 @@ def api_name(collection_name): """ return APIManager.APINAME_FORMAT.format(collection_name) - def collection_name(self, model): - """Returns the name by which the user told us to call collections of - instances of this model. + def model_for(self, collection_name): + """Returns the SQLAlchemy model class whose type is given by the + specified collection name. - `model` is a SQLAlchemy model class. This must be a model on which - :meth:`create_api_blueprint` has been invoked previously. + `collection_name` is a string containing the collection name as + provided to the ``collection_name`` keyword argument to + :meth:`create_api_blueprint`. - """ - return self.created_apis_for[model].collection_name + The collection name should correspond to a model on which + :meth:`create_api_blueprint` has been invoked previously. If it doesn't + this method raises :exc:`ValueError`. - def blueprint_name(self, model): - """Returns the name of the blueprint in which an API was created for - the specified model. + This method is the inverse of :meth:`collection_name`:: - `model` is a SQLAlchemy model class. This must be a model on which - :meth:`create_api_blueprint` has been invoked previously. + >>> from mymodels import Person + >>> manager.create_api(Person, collection_name='people') + >>> manager.collection_name(manager.model_for('people')) + 'people' + >>> manager.model_for(manager.collection_name(Person)) + """ - return self.created_apis_for[model].blueprint_name - - def url_for(self, model, **kw): + # Reverse the dictionary. + models = {info.collection_name: model + for model, info in self.created_apis_for.items()} + try: + return models[collection_name] + except KeyError: + raise ValueError('Collection name {0} unknown. Be sure to set the' + ' `collection_name` keyword argument when calling' + ' `create_api()`.'.format(collection_name)) + + def url_for(self, model, _absolute_url=True, **kw): """Returns the URL for the specified model, similar to :func:`flask.url_for`. - `model` is a SQLAlchemy model class. This must be a model on which - :meth:`create_api_blueprint` has been invoked previously. + `model` is a SQLAlchemy model class. This should be a model on which + :meth:`create_api_blueprint` has been invoked previously. If not, this + method raises a :exc:`ValueError`. + + If `_absolute_url` is ``False``, this function will return just the URL + path without the ``scheme`` and ``netloc`` part of the URL. If it is + ``True``, this function joins the relative URL to the url root for the + current request. This means `_absolute_url` can only be set to ``True`` + if this function is called from within a `Flask request context`_. For + example:: + + >>> from mymodels import Person + >>> manager.create_api(Person) + >>> manager.url_for(Person, instid=3) + 'http://example.com/api/people/3' + >>> manager.url_for(Person, instid=3, _absolute_url=False) + '/api/people/3' This method only returns URLs for endpoints created by this :class:`APIManager`. @@ -215,12 +249,44 @@ def url_for(self, model, **kw): The remaining keyword arguments are passed directly on to :func:`flask.url_for`. + .. _Flask request context: http://flask.pocoo.org/docs/0.10/reqcontext/ + """ - collection_name = self.collection_name(model) + try: + collection_name = self.created_apis_for[model].collection_name + blueprint_name = self.created_apis_for[model].blueprint_name + except KeyError: + raise ValueError('Model {0} unknown. Maybe you need to call' + ' `create_api()`?'.format(model)) api_name = APIManager.api_name(collection_name) - blueprint_name = self.blueprint_name(model) - joined = '.'.join([blueprint_name, api_name]) - return flask.url_for(joined, **kw) + parts = [blueprint_name, api_name] + # If we are looking for a relationship URL, the view name ends with + # '.links'. + if 'relationship' in kw and kw.pop('relationship'): + parts.append('links') + url = flask.url_for('.'.join(parts), **kw) + if _absolute_url: + url = urljoin(request.url_root, url) + return url + + def collection_name(self, model): + """Returns the collection name for the specified model, as specified by + the ``collection_name`` keyword argument to + :meth:`create_api_blueprint`. + + `model` is a SQLAlchemy model class. This should be a model on which + :meth:`create_api_blueprint` has been invoked previously. If not, this + method raises a :exc:`ValueError`. + + This method only returns URLs for endpoints created by this + :class:`APIManager`. + + """ + try: + return self.created_apis_for[model].collection_name + except KeyError: + raise ValueError('Model {0} unknown. Maybe you need to call' + ' `create_api()`?'.format(model)) def init_app(self, app, session=None, flask_sqlalchemy_db=None, preprocessors=None, postprocessors=None): @@ -325,11 +391,14 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS, allow_patch_many=False, allow_delete_many=False, allow_functions=False, exclude_columns=None, include_columns=None, include_methods=None, - validation_exceptions=None, results_per_page=10, - max_results_per_page=100, + validation_exceptions=None, page_size=10, + max_page_size=100, post_form_preprocessor=None, preprocessors=None, postprocessors=None, primary_key=None, - serializer=None, deserializer=None): + serializer=None, deserializer=None, + includes=None, allow_to_many_replacement=False, + allow_delete_from_to_many_relationships=False, + allow_client_generated_ids=False): """Creates and returns a ReSTful API interface as a blueprint, but does not register it on any :class:`flask.Flask` application. @@ -556,10 +625,10 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS, instance_methods = \ methods & frozenset(('GET', 'PATCH', 'DELETE', 'PUT')) possibly_empty_instance_methods = methods & frozenset(('GET', )) - if allow_patch_many and ('PATCH' in methods or 'PUT' in methods): - possibly_empty_instance_methods |= frozenset(('PATCH', 'PUT')) - if allow_delete_many and 'DELETE' in methods: - possibly_empty_instance_methods |= frozenset(('DELETE', )) + # if allow_patch_many and ('PATCH' in methods or 'PUT' in methods): + # possibly_empty_instance_methods |= frozenset(('PATCH', 'PUT')) + # if allow_delete_many and 'DELETE' in methods: + # possibly_empty_instance_methods |= frozenset(('DELETE', )) # Check that primary_key is included for no_instance_methods if no_instance_methods: @@ -585,12 +654,23 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS, postprocessors_[key] = value + postprocessors_[key] # the view function for the API for this model api_view = API.as_view(apiname, restlessinfo.session, model, - exclude_columns, include_columns, - include_methods, validation_exceptions, - results_per_page, max_results_per_page, - post_form_preprocessor, preprocessors_, - postprocessors_, primary_key, serializer, - deserializer) + # Keyword arguments for APIBase.__init__() + preprocessors=preprocessors_, + postprocessors=postprocessors_, + primary_key=primary_key, + validation_exceptions=validation_exceptions, + allow_to_many_replacement=allow_to_many_replacement, + # Keyword arguments for API.__init__() + exclude_columns=exclude_columns, + include_columns=include_columns, + include_methods=include_methods, + page_size=page_size, + max_page_size=max_page_size, + serializer=serializer, + deserializer=deserializer, + includes=includes, + allow_client_generated_ids=allow_client_generated_ids, + allow_delete_many=allow_delete_many) # suffix an integer to apiname according to already existing blueprints blueprintname = APIManager._next_blueprint_name(app.blueprints, apiname) @@ -602,13 +682,13 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS, # TODO should the url_prefix be specified here or in register_blueprint blueprint = Blueprint(blueprintname, __name__, url_prefix=url_prefix) # For example, /api/person. - blueprint.add_url_rule(collection_endpoint, - methods=no_instance_methods, view_func=api_view) + # blueprint.add_url_rule(collection_endpoint, + # methods=['GET', 'POST'], view_func=api_view) # For example, /api/person/1. blueprint.add_url_rule(collection_endpoint, defaults={'instid': None, 'relationname': None, 'relationinstid': None}, - methods=possibly_empty_instance_methods, + methods=frozenset(['GET', 'POST', 'DELETE']) & methods, view_func=api_view) # the per-instance endpoints will allow both integer and string primary # key accesses @@ -618,19 +698,41 @@ def create_api_blueprint(self, model, app=None, methods=READONLY_METHODS, defaults={'relationname': None, 'relationinstid': None}, view_func=api_view) - # add endpoints which expose related models - relation_endpoint = '{0}/'.format(instance_endpoint) + # Create related resource URLs. + relation_endpoint = \ + '{0}/'.format(instance_endpoint) relation_instance_endpoint = \ '{0}/'.format(relation_endpoint) # For example, /api/person/1/computers. blueprint.add_url_rule(relation_endpoint, - methods=possibly_empty_instance_methods, + methods=frozenset(['GET', 'PUT', 'POST', + 'DELETE']) & methods, defaults={'relationinstid': None}, view_func=api_view) # For example, /api/person/1/computers/2. blueprint.add_url_rule(relation_instance_endpoint, methods=instance_methods, view_func=api_view) + + # Create relationship URL endpoints. + RAPI = RelationshipAPI + relationship_api_name = '{0}.links'.format(apiname) + relationship_api_view = \ + RAPI.as_view(relationship_api_name, + restlessinfo.session, model, + # Keyword arguments for APIBase.__init__() + preprocessors=preprocessors_, + postprocessors=postprocessors_, + primary_key=primary_key, + validation_exceptions=validation_exceptions, + allow_to_many_replacement=allow_to_many_replacement, + # Keyword arguments RelationshipAPI.__init__() + allow_delete_from_to_many_relationships=allow_delete_from_to_many_relationships) + relationship_endpoint = '{0}/links/'.format(instance_endpoint) + blueprint.add_url_rule(relationship_endpoint, + methods=frozenset(['PUT', 'POST', 'DELETE']) & methods, + view_func=relationship_api_view) + # if function evaluation is allowed, add an endpoint at /api/eval/... # which responds only to GET requests and responds with the result of # evaluating functions on all instances of the specified model diff --git a/flask_restless/search.py b/flask_restless/search.py index bea5fdcc..a72c4749 100644 --- a/flask_restless/search.py +++ b/flask_restless/search.py @@ -21,9 +21,11 @@ from sqlalchemy.ext.associationproxy import AssociationProxy from sqlalchemy.orm.attributes import InstrumentedAttribute -from .helpers import session_query +from .helpers import get_related_model from .helpers import get_related_association_proxy_model from .helpers import primary_key_names +from .helpers import session_query +from .helpers import string_to_datetime def _sub_operator(model, argument, fieldname): @@ -186,7 +188,7 @@ def __repr__(self): self.argument or self.otherfield) @staticmethod - def from_dictionary(dictionary): + def from_dictionary(model, dictionary): """Returns a new :class:`Filter` object with arguments parsed from `dictionary`. @@ -222,8 +224,10 @@ def from_dictionary(dictionary): if 'or' not in dictionary and 'and' not in dictionary: fieldname = dictionary.get('name') operator = dictionary.get('op') - argument = dictionary.get('val') otherfield = dictionary.get('field') + argument = dictionary.get('val') + # Need to deal with the special case of converting dates. + argument = string_to_datetime(model, fieldname, argument) return Filter(fieldname, operator, argument, otherfield) # For the sake of brevity, rename this method. from_dict = Filter.from_dictionary @@ -231,15 +235,19 @@ def from_dictionary(dictionary): # provided list of filters. if 'or' in dictionary: subfilters = dictionary.get('or') - return DisjunctionFilter(*(from_dict(f) for f in subfilters)) + return DisjunctionFilter(*[from_dict(model, filter_) + for filter_ in subfilters]) if 'and' in dictionary: subfilters = dictionary.get('and') - return ConjunctionFilter(*(from_dict(f) for f in subfilters)) + return ConjunctionFilter(*[from_dict(model, filter_) + for filter_ in subfilters]) class JunctionFilter(Filter): + def __init__(self, *subfilters): self.subfilters = subfilters + def __iter__(self): return iter(self.subfilters) @@ -444,7 +452,8 @@ def _create_filter(model, filt): return or_(create_filt(model, f) for f in filt) @staticmethod - def create_query(session, model, search_params, _ignore_order_by=False): + def create_query(session, model, filters=None, sort=None, + _ignore_order_by=False): """Builds an SQLAlchemy query instance based on the search parameters present in ``search_params``, an instance of :class:`SearchParameters`. @@ -474,82 +483,80 @@ def create_query(session, model, search_params, _ignore_order_by=False): """ query = session_query(session, model) - # For the sake of brevity, rename this method. - create_filt = QueryBuilder._create_filter + + # Filter the query. + filters = [Filter.from_dictionary(model, f) for f in filters] # This function call may raise an exception. - filters = [create_filt(model, filt) for filt in search_params.filters] - # Multiple filter criteria at the top level of the provided search - # parameters are interpreted as a conjunction (AND). + filters = [QueryBuilder._create_filter(model, f) for f in filters] query = query.filter(*filters) - # Order the search. If no order field is specified in the search - # parameters, order by primary key. + # Order the query. If no order field is specified, order by primary + # key. if not _ignore_order_by: - if search_params.order_by: - for val in search_params.order_by: - field_name = val.field - if '__' in field_name: + if sort: + for (symbol, field_name) in sort: + direction_name = 'asc' if symbol is '+' else 'desc' + if '.' in field_name: field_name, field_name_in_relation = \ - field_name.split('__') - relation = getattr(model, field_name) - relation_model = relation.mapper.class_ + field_name.split('.') + relation_model = get_related_model(model, field_name) field = getattr(relation_model, field_name_in_relation) - direction = getattr(field, val.direction) + direction = getattr(field, direction_name) query = query.join(relation_model) query = query.order_by(direction()) else: - field = getattr(model, val.field) - direction = getattr(field, val.direction) + field = getattr(model, field_name) + direction = getattr(field, direction_name) query = query.order_by(direction()) else: pks = primary_key_names(model) pk_order = (getattr(model, field).asc() for field in pks) query = query.order_by(*pk_order) - # Group the query. - if search_params.group_by: - for groupby in search_params.group_by: - field = getattr(model, groupby.field) - query = query.group_by(field) + # # Group the query. + # if search_params.group_by: + # for groupby in search_params.group_by: + # field = getattr(model, groupby.field) + # query = query.group_by(field) - # Apply limit and offset to the query. - if search_params.limit: - query = query.limit(search_params.limit) - if search_params.offset: - query = query.offset(search_params.offset) + # # Apply limit and offset to the query. + # if search_params.limit: + # query = query.limit(search_params.limit) + # if search_params.offset: + # query = query.offset(search_params.offset) return query -def create_query(session, model, searchparams, _ignore_order_by=False): - """Returns a SQLAlchemy query object on the given `model` where the search - for the query is defined by `searchparams`. +# def create_query(session, model, sort, _ignore_order_by=False): +# """Returns a SQLAlchemy query object on the given `model` where the search +# for the query is defined by `searchparams`. - The returned query matches the set of all instances of `model` which meet - the parameters of the search given by `searchparams`. For more information - on search parameters, see :ref:`search`. +# The returned query matches the set of all instances of `model` which meet +# the parameters of the search given by `searchparams`. For more information +# on search parameters, see :ref:`search`. - `model` is a SQLAlchemy declarative model representing the database model - to query. +# `model` is a SQLAlchemy declarative model representing the database model +# to query. - `searchparams` is either a dictionary (as parsed from a JSON request from - the client, for example) or a :class:`SearchParameters` instance defining - the parameters of the query (as returned by - :func:`SearchParameters.from_dictionary`, for example). +# `searchparams` is either a dictionary (as parsed from a JSON request from +# the client, for example) or a :class:`SearchParameters` instance defining +# the parameters of the query (as returned by +# :func:`SearchParameters.from_dictionary`, for example). - If `_ignore_order_by` is ``True``, no ``order_by`` method will be called on - the query, regardless of whether the search parameters indicate that there - should be an ``order_by``. (This is used internally by Flask-Restless to - work around a limitation in SQLAlchemy.) +# If `_ignore_order_by` is ``True``, no ``order_by`` method will be called on +# the query, regardless of whether the search parameters indicate that there +# should be an ``order_by``. (This is used internally by Flask-Restless to +# work around a limitation in SQLAlchemy.) - """ - if isinstance(searchparams, dict): - searchparams = SearchParameters.from_dictionary(searchparams) - return QueryBuilder.create_query(session, model, searchparams, - _ignore_order_by) +# """ +# # if isinstance(searchparams, dict): +# # searchparams = SearchParameters.from_dictionary(searchparams) +# return QueryBuilder.create_query(session, model, sort, _ignore_order_by) -def search(session, model, search_params, _ignore_order_by=False): +def search(session, model, filters=None, sort=None, single=False, + _ignore_order_by=False): """Performs the search specified by the given parameters on the model specified in the constructor of this class. @@ -568,6 +575,11 @@ def search(session, model, search_params, _ignore_order_by=False): `model` is a SQLAlchemy declarative model class representing the database model to query. + `sort` is a list of two-tuples of the form ``(direction, fieldname)``, + where ``direction`` is either ``'+'`` or ``'-'`` and ``fieldname`` is a + string representing an attribute of the model or a dot-separated + relationship path (for example, ``'owner.name'``). + `search_params` is a dictionary containing all available search parameters. For more information on available search parameters, see :ref:`search`. Implementation note: this dictionary will be converted to a @@ -580,12 +592,13 @@ def search(session, model, search_params, _ignore_order_by=False): work around a limitation in SQLAlchemy.) """ - # `is_single` is True when 'single' is a key in ``search_params`` and its - # corresponding value is anything except those values which evaluate to - # False (False, 0, the empty string, the empty list, etc.). - is_single = search_params.get('single') - query = create_query(session, model, search_params, _ignore_order_by) - if is_single: + # # `is_single` is True when 'single' is a key in ``search_params`` and its + # # corresponding value is anything except those values which evaluate to + # # False (False, 0, the empty string, the empty list, etc.). + # is_single = search_params.get('single') + query = QueryBuilder.create_query(session, model, filters, sort, + _ignore_order_by) + if single: # may raise NoResultFound or MultipleResultsFound return query.one() return query diff --git a/flask_restless/views.py b/flask_restless/views.py index 677efddf..b1103bdb 100644 --- a/flask_restless/views.py +++ b/flask_restless/views.py @@ -26,8 +26,8 @@ from collections import defaultdict from functools import wraps +from itertools import chain import math -import warnings from flask import current_app from flask import json @@ -35,20 +35,22 @@ from flask import request from flask.views import MethodView from mimerender import FlaskMimeRender +from mimerender import register_mime from sqlalchemy import Column from sqlalchemy.exc import DataError from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import OperationalError from sqlalchemy.exc import ProgrammingError -from sqlalchemy.ext.associationproxy import AssociationProxy -from sqlalchemy.orm.attributes import InstrumentedAttribute +# from sqlalchemy.ext.associationproxy import AssociationProxy +# from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.orm.exc import FlushError from sqlalchemy.orm.exc import MultipleResultsFound from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.query import Query from werkzeug.exceptions import BadRequest from werkzeug.exceptions import HTTPException -from werkzeug.urls import url_quote_plus +from .helpers import collection_name from .helpers import count from .helpers import evaluate_functions from .helpers import get_by @@ -58,20 +60,22 @@ from .helpers import get_relations from .helpers import has_field from .helpers import is_like_list +from .helpers import model_for from .helpers import partition from .helpers import primary_key_name -from .helpers import query_by_primary_key +from .helpers import primary_key_value from .helpers import session_query -from .helpers import strings_to_dates +from .helpers import strings_to_datetimes from .helpers import to_dict from .helpers import upper_keys -from .helpers import get_related_association_proxy_model -from .search import create_query +from .helpers import url_for +# from .helpers import get_related_association_proxy_model +# from .search import create_query from .search import search -#: Format string for creating Link headers in paginated responses. -LINKTEMPLATE = '<{0}?page={1}&results_per_page={2}>; rel="{3}"' +#: Format string for creating the complete URL for a paginated response. +LINKTEMPLATE = '{0}?page[number]={1}&page[size]={2}' #: String used internally as a dictionary key for passing header information #: from view functions to the :func:`jsonpify` function. @@ -81,6 +85,18 @@ #: information from view functions to the :func:`jsonpify` function. _STATUS = '__restless_status_code' +#: The Content-Type we expect for most requests to APIs. +#: +#: The JSON API specification requires the content type to be +#: ``application/vnd.api+json``. +CONTENT_TYPE = 'application/vnd.api+json' + +#: SQLAlchemy errors that, when caught, trigger a rollback of the session. +ROLLBACK_ERRORS = (DataError, IntegrityError, ProgrammingError, FlushError) + +# For the sake of brevity, rename this function. +chain = chain.from_iterable + class ProcessingException(HTTPException): """Raised when a preprocessor or postprocessor encounters a problem. @@ -129,23 +145,6 @@ def _is_msie8or9(): and (8, 0) <= version(request.user_agent) < (10, 0)) -def create_link_string(page, last_page, per_page): - """Returns a string representing the value of the ``Link`` header. - - `page` is the number of the current page, `last_page` is the last page in - the pagination, and `per_page` is the number of results per page. - - """ - linkstring = '' - if page < last_page: - next_page = page + 1 - linkstring = LINKTEMPLATE.format(request.base_url, next_page, - per_page, 'next') + ', ' - linkstring += LINKTEMPLATE.format(request.base_url, last_page, - per_page, 'last') - return linkstring - - def catch_processing_exceptions(func): """Decorator that catches :exc:`ProcessingException`s and subsequently returns a JSON-ified error response. @@ -188,14 +187,25 @@ def wrapped(*args, **kw): try: return func(*args, **kw) # TODO should `sqlalchemy.exc.InvalidRequestError`s also be caught? - except (DataError, IntegrityError, ProgrammingError) as exception: + except ROLLBACK_ERRORS as exception: session.rollback() current_app.logger.exception(str(exception)) - return dict(message=type(exception).__name__), 400 + # Special status code for conflicting instances: 409 Conflict + status = 409 if is_conflict(exception) else 400 + return dict(message=type(exception).__name__), status return wrapped return decorator +def is_conflict(exception): + """Returns ``True`` if and only if the specified exception represents a + conflict in the database. + + """ + string = str(exception) + return 'conflicts with' in string or 'UNIQUE constraint failed' in string + + def set_headers(response, headers): """Sets the specified headers on the specified response. @@ -205,7 +215,7 @@ def set_headers(response, headers): """ for key, value in headers.items(): - response.headers[key] = value + response.headers.set(key, value) def jsonify(*args, **kw): @@ -305,24 +315,30 @@ def jsonpify(*args, **kw): if callback: # Reload the data from the constructed JSON string so we can wrap it in # a JSONP function. - data = json.loads(response.data) + document = json.loads(response.data) # Force the 'Content-Type' header to be 'application/javascript'. # # Note that this is different from the mimetype used in Flask for JSON # responses; Flask uses 'application/json'. We use # 'application/javascript' because a JSONP response is valid - # Javascript, but not valid JSON. - headers['Content-Type'] = 'application/javascript' + # Javascript, but not valid JSON (and not a valid JSON API document). + mimetype = 'application/javascript' + headers['Content-Type'] = mimetype # Add the headers and status code as metadata to the JSONP response. meta = _headers_to_json(headers) if headers is not None else {} meta['status'] = status_code - inner = json.dumps(dict(meta=meta, data=data)) + if 'meta' in document: + document['meta'].update(meta) + else: + document['meta'] = meta + inner = json.dumps(document) content = '{0}({1})'.format(callback, inner) # Note that this is different from the mimetype used in Flask for JSON # responses; Flask uses 'application/json'. We use # 'application/javascript' because a JSONP response is not valid JSON. - mimetype = 'application/javascript' response = current_app.response_class(content, mimetype=mimetype) + if 'Content-Type' not in headers: + headers['Content-Type'] = CONTENT_TYPE # Set the headers on the HTTP response as well. if headers: set_headers(response, headers) @@ -358,6 +374,14 @@ def _parse_includes(column_names): return columns, relations +def parse_sparse_fields(): + # TODO use a regular expression to ensure field parameters are of the + # correct format? (maybe ``field\[[^\[\]\.]*\]``) + return {key[7:-1]: set(value.split(',')) + for key, value in request.args.items() + if key.startswith('fields[') and key.endswith(']')} + + def _parse_excludes(column_names): """Returns a pair, consisting of a list of column names to exclude on the left and a dictionary mapping relation name to a list containing the names @@ -386,6 +410,7 @@ def _parse_excludes(column_names): return columns, relations +# TODO these need to become JSON Pointers def extract_error_messages(exception): """Tries to extract a dictionary mapping field name to validation error messages from `exception`, which is a validation exception as provided in @@ -399,6 +424,9 @@ def extract_error_messages(exception): error messages dictionary can be extracted). """ + # Check for our own built-in validation error. + if isinstance(exception, ValidationError): + return exception.args[0] # 'errors' comes from sqlalchemy_elixir_validations if hasattr(exception, 'errors'): return exception.errors @@ -418,6 +446,60 @@ def extract_error_messages(exception): return {fieldname: msg} return None + +def error(id=None, href=None, status=None, code=None, title=None, + detail=None, links=None, paths=None): + # HACK We use locals() so we don't have to list every keyword argument. + if all(kwvalue is None for kwvalue in locals().values()): + raise ValueError('At least one of the arguments must not be None.') + return dict(id=id, href=href, status=status, code=code, title=title, + detail=detail, links=links, paths=paths) + + +def error_response(status, **kw): + """Returns a correctly formatted error response with the specified + parameters. + + This is a convenience function for:: + + errors_response(status, [error(**kw)]) + + For more information, see :func:`errors_response`. + + """ + return errors_response(status, [error(**kw)]) + + +def errors_response(status, errors): + """Return an error response with multiple errors. + + `status` is an integer representing an HTTP status code corresponding to an + error response. + + `errors` is a list of error dictionaries, each of which must satisfy the + requirements of the JSON API specification. + + This function returns a two-tuple whose left element is a dictionary + containing the errors under the top-level key ``errors`` and whose right + element is `status`. + + The returned dictionary object also includes a key with a special name, + stored in the key :data:`_STATUS`, which is used to workaround an + incompatibility between Flask and mimerender that doesn't allow setting + headers on a global response object. + + The keys within each error object are described in the `Errors`_ section of + the JSON API specification. + + .. _Errors: http://jsonapi.org/format/#errors + + """ + return {'errors': errors, _STATUS: status}, status + + +# Register the JSON API content type so that mimerender knows to look for it. +register_mime('jsonapi', (CONTENT_TYPE, )) + #: Creates the mimerender object necessary for decorating responses with a #: function that automatically formats the dictionary in the appropriate format #: based on the ``Accept`` header. @@ -427,7 +509,7 @@ def extract_error_messages(exception): #: creates the decorator, so that we can simply use the variable ``mimerender`` #: as a decorator. # TODO fill in xml renderer -mimerender = FlaskMimeRender()(default='json', json=jsonpify) +mimerender = FlaskMimeRender()(default='jsonapi', jsonapi=jsonpify) class ModelView(MethodView): @@ -514,7 +596,51 @@ def get(self): return dict(message=message), 400 -class API(ModelView): +class APIBase(ModelView): + + #: List of decorators applied to every method of this class. + decorators = ModelView.decorators + [catch_processing_exceptions] + + def __init__(self, session, model, preprocessors=None, postprocessors=None, + primary_key=None, validation_exceptions=None, + allow_to_many_replacement=None, *args, **kw): + super(APIBase, self).__init__(session, model, *args, **kw) + self.allow_to_many_replacement = allow_to_many_replacement + self.validation_exceptions = tuple(validation_exceptions or ()) + self.primary_key = primary_key + self.postprocessors = defaultdict(list) + self.preprocessors = defaultdict(list) + self.postprocessors.update(upper_keys(postprocessors or {})) + self.preprocessors.update(upper_keys(preprocessors or {})) + + # HACK: We would like to use the :attr:`API.decorators` class attribute + # in order to decorate each view method with a decorator that catches + # database integrity errors. However, in order to rollback the session, + # we need to have a session object available to roll back. Therefore we + # need to manually decorate each of the view functions here. + decorate = lambda name, f: setattr(self, name, f(getattr(self, name))) + for method in ['get', 'post', 'patch', 'put', 'delete']: + # Check if the subclass has the method before trying to decorate + # it. + if hasattr(self, method): + decorate(method, catch_integrity_errors(self.session)) + + def _handle_validation_exception(self, exception): + """Rolls back the session, extracts validation error messages, and + returns a :func:`flask.jsonify` response with :http:statuscode:`400` + containing the extracted validation error messages. + + Again, *this method calls + :meth:`sqlalchemy.orm.session.Session.rollback`*. + + """ + self.session.rollback() + errors = extract_error_messages(exception) or \ + 'Could not determine specific validation errors' + return errors_response(400, errors) + + +class API(APIBase): """Provides method-based dispatching for :http:method:`get`, :http:method:`post`, :http:method:`patch`, :http:method:`put`, and :http:method:`delete` requests, for both collections of models and @@ -522,15 +648,12 @@ class API(ModelView): """ - #: List of decorators applied to every method of this class. - decorators = ModelView.decorators + [catch_processing_exceptions] - def __init__(self, session, model, exclude_columns=None, include_columns=None, include_methods=None, - validation_exceptions=None, results_per_page=10, - max_results_per_page=100, post_form_preprocessor=None, - preprocessors=None, postprocessors=None, primary_key=None, - serializer=None, deserializer=None, *args, **kw): + page_size=10, max_page_size=100, serializer=None, + deserializer=None, includes=None, + allow_client_generated_ids=False, allow_delete_many=False, + *args, **kw): """Instantiates this view with the specified attributes. `session` is the SQLAlchemy session in which all database transactions @@ -539,6 +662,9 @@ def __init__(self, session, model, exclude_columns=None, `model` is the SQLAlchemy model class for which this instance of the class is an API. This model should live in `database`. + `collection_name` is a string by which a collection of instances of + `model` are presented to the user. + `validation_exceptions` is the tuple of exceptions raised by backend validation (if any exist). If exceptions are specified here, any exceptions which are caught when writing to the database. Will be @@ -673,10 +799,21 @@ class is an API. This model should live in `database`. self.include_columns, self.include_relations = _parse_includes( [self._get_column_name(column) for column in include_columns]) self.include_methods = include_methods - self.validation_exceptions = tuple(validation_exceptions or ()) - self.results_per_page = results_per_page - self.max_results_per_page = max_results_per_page - self.primary_key = primary_key + # TODO this keyword argument doesn't exist yet + # + # self.default_fields = fields + # + self.default_fields = None + if self.default_fields is not None: + self.default_fields = frozenset(self.default_fields) + self.default_includes = includes + if self.default_includes is not None: + self.default_includes = frozenset(self.default_includes) + self.collection_name = collection_name(self.model) + self.page_size = page_size + self.max_page_size = max_page_size + self.allow_client_generated_ids = allow_client_generated_ids + self.allow_delete_many = allow_delete_many # Use our default serializer and deserializer if none are specified. if serializer is None: self.serialize = self._inst_to_dict @@ -689,36 +826,6 @@ class is an API. This model should live in `database`. + [ValidationError]) else: self.deserialize = deserializer - self.postprocessors = defaultdict(list) - self.preprocessors = defaultdict(list) - self.postprocessors.update(upper_keys(postprocessors or {})) - self.preprocessors.update(upper_keys(preprocessors or {})) - # move post_form_preprocessor to preprocessors['POST'] for backward - # compatibility - if post_form_preprocessor: - msg = ('post_form_preprocessor is deprecated and will be removed' - ' in version 1.0; use preprocessors instead.') - warnings.warn(msg, DeprecationWarning) - self.preprocessors['POST'].append(post_form_preprocessor) - # postprocessors for PUT are applied to PATCH because PUT is just a - # redirect to PATCH - for postprocessor in self.postprocessors['PUT_SINGLE']: - self.postprocessors['PATCH_SINGLE'].append(postprocessor) - for preprocessor in self.preprocessors['PUT_SINGLE']: - self.preprocessors['PATCH_SINGLE'].append(preprocessor) - for postprocessor in self.postprocessors['PUT_MANY']: - self.postprocessors['PATCH_MANY'].append(postprocessor) - for preprocessor in self.preprocessors['PUT_MANY']: - self.preprocessors['PATCH_MANY'].append(preprocessor) - - # HACK: We would like to use the :attr:`API.decorators` class attribute - # in order to decorate each view method with a decorator that catches - # database integrity errors. However, in order to rollback the session, - # we need to have a session object available to roll back. Therefore we - # need to manually decorate each of the view functions here. - decorate = lambda name, f: setattr(self, name, f(getattr(self, name))) - for method in ['get', 'post', 'patch', 'put', 'delete']: - decorate(method, catch_integrity_errors(self.session)) def _get_column_name(self, column): """Retrieve a column name from a column attribute of SQLAlchemy @@ -904,21 +1011,7 @@ def _update_relations(self, query, params): return tochange - def _handle_validation_exception(self, exception): - """Rolls back the session, extracts validation error messages, and - returns a :func:`flask.jsonify` response with :http:statuscode:`400` - containing the extracted validation error messages. - - Again, *this method calls - :meth:`sqlalchemy.orm.session.Session.rollback`*. - - """ - self.session.rollback() - errors = extract_error_messages(exception) or \ - 'Could not determine specific validation errors' - return dict(validation_errors=errors), 400 - - def _compute_results_per_page(self): + def _compute_page_size(self): """Helper function which returns the number of results per page based on the request argument ``results_per_page`` and the server configuration parameters :attr:`results_per_page` and @@ -926,15 +1019,15 @@ def _compute_results_per_page(self): """ try: - results_per_page = int(request.args.get('results_per_page')) + page_size = int(request.args.get('page[size]')) except: - results_per_page = self.results_per_page - if results_per_page <= 0: - results_per_page = self.results_per_page - return min(results_per_page, self.max_results_per_page) + page_size = self.page_size + if page_size <= 0: + page_size = self.page_size + return min(page_size, self.max_page_size) # TODO it is ugly to have `deep` as an arg here; can we remove it? - def _paginated(self, instances, deep): + def _paginated(self, instances, type_, deep): """Returns a paginated JSONified response from the specified list of model instances. @@ -961,32 +1054,37 @@ def _paginated(self, instances, deep): num_results = len(instances) else: num_results = count(self.session, instances) - results_per_page = self._compute_results_per_page() - if results_per_page > 0: + page_size = self._compute_page_size() + if page_size > 0: # get the page number (first page is page 1) - page_num = int(request.args.get('page', 1)) - start = (page_num - 1) * results_per_page - end = min(num_results, start + results_per_page) - total_pages = int(math.ceil(num_results / results_per_page)) + page_num = int(request.args.get('page[number]', 1)) + start = (page_num - 1) * page_size + end = min(num_results, start + page_size) + total_pages = int(math.ceil(num_results / page_size)) else: page_num = 1 start = 0 end = num_results total_pages = 1 - objects = [to_dict(x, deep, exclude=self.exclude_columns, + objects = [to_dict(x, type_=type_, deep=deep, + exclude=self.exclude_columns, exclude_relations=self.exclude_relations, include=self.include_columns, include_relations=self.include_relations, include_methods=self.include_methods) for x in instances[start:end]] - return dict(page=page_num, objects=objects, total_pages=total_pages, - num_results=num_results) + return dict(meta=dict(page=page_num, total_pages=total_pages, + num_results=num_results), + objects=objects) - def _inst_to_dict(self, inst): + def _inst_to_dict(self, inst, only=None): """Returns the dictionary representation of the specified instance. - This method respects the include and exclude columns specified in the - constructor of this class. + If `only` is specified, only the attributes whose names are given as + strings in this set appear in the returned dictionary. + + If `only` is not specified, this method uses the include and exclude + columns specified in the constructor of this class. """ # create a placeholder for the relations of the returned models @@ -998,62 +1096,101 @@ def _inst_to_dict(self, inst): relations &= (cols | rels) elif self.exclude_columns is not None: relations -= frozenset(self.exclude_columns) - deep = dict((r, {}) for r in relations) - return to_dict(inst, deep, exclude=self.exclude_columns, - exclude_relations=self.exclude_relations, - include=self.include_columns, - include_relations=self.include_relations, - include_methods=self.include_methods) + # Always include at least the type and ID, regardless of what the user + # requested. + if only is not None: + if 'type' not in only: + only.add('type') + if 'id' not in only: + only.add('id') + result = to_dict(inst, only) + return result def _dict_to_inst(self, data): """Returns an instance of the model with the specified attributes.""" # Check for any request parameter naming a column which does not exist # on the current model. for field in data: - if not has_field(self.model, field): + if field == 'links': + for relation in data['links']: + if not has_field(self.model, relation): + msg = ('Model does not have relationship' + ' "{0}"').format(relation) + raise ValidationError(msg) + elif not has_field(self.model, field): msg = "Model does not have field '{0}'".format(field) raise ValidationError(msg) - - # Getting the list of relations that will be added later - cols = get_columns(self.model) - relations = get_relations(self.model) - - # Looking for what we're going to set on the model right now - colkeys = cols.keys() - paramkeys = data.keys() - props = set(colkeys).intersection(paramkeys).difference(relations) - + # Determine which related instances need to be added. + links = {} + if 'links' in data: + links = data.pop('links', {}) + for link_name, link_object in links.items(): + related_model = get_related_model(self.model, link_name) + # If this is a to-one relationship, just get a single instance. + if 'id' in link_object: + id_ = link_object['id'] + related_instance = get_by(self.session, related_model, id_) + links[link_name] = related_instance + # Otherwise, if this is a to-many relationship, get all the + # instances. + elif 'ids' in link_object: + related_instances = [get_by(self.session, related_model, d) + for d in link_object['ids']] + links[link_name] = related_instances + else: + # TODO raise an error here + pass + # TODO Need to check here if any related instances are None, like we do + # in the put() method. + pass # Special case: if there are any dates, convert the string form of the # date into an instance of the Python ``datetime`` object. - data = strings_to_dates(self.model, data) - - # Instantiate the model with the parameters. - modelargs = dict([(i, data[i]) for i in props]) - instance = self.model(**modelargs) - - # Handling relations, a single level is allowed - for col in set(relations).intersection(paramkeys): - submodel = get_related_model(self.model, col) - - if type(data[col]) == list: - # model has several related objects - for subparams in data[col]: - subinst = get_or_create(self.session, submodel, - subparams) - try: - getattr(instance, col).append(subinst) - except AttributeError: - attribute = getattr(instance, col) - attribute[subinst.key] = subinst.value - else: - # model has single related object - subinst = get_or_create(self.session, submodel, - data[col]) - setattr(instance, col, subinst) - + # + # TODO This should be done as part of _dict_to_inst(), not done on its + # own here. + data = strings_to_datetimes(self.model, data) + # Create the new instance by keyword attributes. + instance = self.model(**data) + # Set each relation specified in the links. + for relation_name, related_value in links.items(): + setattr(instance, relation_name, related_value) return instance - - def _instid_to_dict(self, instid): + # # Getting the list of relations that will be added later + # cols = get_columns(self.model) + # relations = get_relations(self.model) + + # # Looking for what we're going to set on the model right now + # colkeys = cols.keys() + # paramkeys = (data.keys() - {'links'}) | data.get('links', {}).keys() + # props = set(colkeys).intersection(paramkeys).difference(relations) + + # # Special case: if there are any dates, convert the string form of the + # # date into an instance of the Python ``datetime`` object. + # data = strings_to_dates(self.model, data) + # # Instantiate the model with the parameters. + # modelargs = dict([(i, data[i]) for i in props]) + # instance = self.model(**modelargs) + # # Handling relations, a single level is allowed + # for col in set(relations).intersection(paramkeys): + # submodel = get_related_model(self.model, col) + # if type(data['links'][col]) == list: + # # model has several related objects + # for subparams in data[col]: + # subinst = get_or_create(self.session, submodel, + # subparams) + # try: + # getattr(instance, col).append(subinst) + # except AttributeError: + # attribute = getattr(instance, col) + # attribute[subinst.key] = subinst.value + # else: + # # model has single related object + # subinst = get_or_create(self.session, submodel, + # data[col]) + # setattr(instance, col, subinst) + # return instance + + def _instid_to_dict(self, instid, only=None): """Returns the dictionary representation of the instance specified by `instid`. @@ -1064,7 +1201,7 @@ def _instid_to_dict(self, instid): inst = get_by(self.session, self.model, instid, self.primary_key) if inst is None: return {_STATUS: 404}, 404 - return self._inst_to_dict(inst) + return self._inst_to_dict(inst, only) def _search(self): """Defines a generic search function for the database model. @@ -1133,110 +1270,204 @@ def _search(self): responses, see :ref:`searchformat`. """ - # try to get search query from the request query parameters + # try: + # # Get any sorting parameters commands. + # sorting = json.loads(request.args.get('filters', '{}')) + # except (TypeError, ValueError, OverflowError) as exception: + # current_app.logger.exception(str(exception)) + # detail = 'Unable to decode sorting data as JSON' + # return error_response(400, detail=detail) + + # # resolve date-strings as required by the model + # for param in search_params.get('filters', list()): + # if 'name' in param and 'val' in param: + # query_model = self.model + # query_field = param['name'] + # if '__' in param['name']: + # fieldname, relation = param['name'].split('__') + # submodel = getattr(self.model, fieldname) + # if isinstance(submodel, InstrumentedAttribute): + # query_model = submodel.property.mapper.class_ + # query_field = relation + # elif isinstance(submodel, AssociationProxy): + # # For the sake of brevity, rename this function. + # get_assoc = get_related_association_proxy_model + # query_model = get_assoc(submodel) + # query_field = relation + # to_convert = {query_field: param['val']} + # try: + # result = strings_to_dates(query_model, to_convert) + # except ValueError as exception: + # current_app.logger.exception(str(exception)) + # return dict(message='Unable to construct query'), 400 + # param['val'] = result.get(query_field) + + # Determine filtering options. try: - search_params = json.loads(request.args.get('q', '{}')) + filters = json.loads(request.args.get('filter[objects]', '[]')) except (TypeError, ValueError, OverflowError) as exception: current_app.logger.exception(str(exception)) - return dict(message='Unable to decode data'), 400 + detail = 'Unable to decode filter objects as JSON list' + return error_response(400, detail=detail) + # TODO fix this + #filters = [strings_to_dates(self.model, f) for f in filters] + + + # Determine sorting options. + sort = request.args.get('sort') + if sort: + sort = [(value[0], value[1:]) for value in sort.split(',')] + else: + sort = [] + if any(order not in ('+', '-') for order, field in sort): + detail = 'Each sort parameter must begin with "+" or "-".' + return error_response(400, detail=detail) + + # Determine whether the client expects a single resource response. + try: + single = bool(int(request.args.get('filter[single]', 0))) + except ValueError as exception: + current_app.logger.exception(str(exception)) + detail = 'Invalid format for filter[single] query parameter' + return error_response(400, detail=detail) for preprocessor in self.preprocessors['GET_MANY']: - preprocessor(search_params=search_params) - - # resolve date-strings as required by the model - for param in search_params.get('filters', list()): - if 'name' in param and 'val' in param: - query_model = self.model - query_field = param['name'] - if '__' in param['name']: - fieldname, relation = param['name'].split('__') - submodel = getattr(self.model, fieldname) - if isinstance(submodel, InstrumentedAttribute): - query_model = submodel.property.mapper.class_ - query_field = relation - elif isinstance(submodel, AssociationProxy): - # For the sake of brevity, rename this function. - get_assoc = get_related_association_proxy_model - query_model = get_assoc(submodel) - query_field = relation - to_convert = {query_field: param['val']} - try: - result = strings_to_dates(query_model, to_convert) - except ValueError as exception: - current_app.logger.exception(str(exception)) - return dict(message='Unable to construct query'), 400 - param['val'] = result.get(query_field) + preprocessor(filters=filters, sort=sort, single=single) - # perform a filtered search + # Compute the result of the search on the model. try: - result = search(self.session, self.model, search_params) + result = search(self.session, self.model, + filters=filters, sort=sort, single=single) except NoResultFound: - return dict(message='No result found'), 404 + return error_response(404, detail='No result found') except MultipleResultsFound: - return dict(message='Multiple results found'), 400 + return error_response(404, detail='Multiple results found') except Exception as exception: current_app.logger.exception(str(exception)) - return dict(message='Unable to construct query'), 400 - - # create a placeholder for the relations of the returned models - relations = frozenset(get_relations(self.model)) - # do not follow relations that will not be included in the response - if self.include_columns is not None: - cols = frozenset(self.include_columns) - rels = frozenset(self.include_relations) - relations &= (cols | rels) - elif self.exclude_columns is not None: - relations -= frozenset(self.exclude_columns) - deep = dict((r, {}) for r in relations) - - # for security purposes, don't transmit list as top-level JSON + return error_response(400, detail='Unable to construct query') + + # # create a placeholder for the relations of the returned models + # relations = frozenset(get_relations(self.model)) + # # do not follow relations that will not be included in the response + # if self.include_columns is not None: + # cols = frozenset(self.include_columns) + # rels = frozenset(self.include_relations) + # relations &= (cols | rels) + # elif self.exclude_columns is not None: + # relations -= frozenset(self.exclude_columns) + # deep = dict((r, {}) for r in relations) + + # Determine fields to include for each type of object. + fields = parse_sparse_fields() + if self.collection_name in fields and self.default_fields is not None: + fields[self.collection_name] |= self.default_fields + fields = fields.get(self.collection_name) + + # If the result of the search is a SQLAlchemy query object, we need to + # return a collection. + pagination_links = dict() if isinstance(result, Query): - result = self._paginated(result, deep) - # Create the Link header. - # - # TODO We are already calling self._compute_results_per_page() once - # in _paginated(); don't compute it again here. - page, last_page = result['page'], result['total_pages'] - linkstring = create_link_string(page, last_page, - self._compute_results_per_page()) - headers = dict(Link=linkstring) + # Determine the client's pagination request: page size and number. + page_size = int(request.args.get('page[size]', self.page_size)) + if page_size < 0: + detail = 'Page size must be a positive integer' + return error_response(400, detail=detail) + if page_size > self.max_page_size: + detail = "Page size must not exceed the server's maximum: {0}" + detail = detail.format(self.max_page_size) + return error_response(400, detail=detail) + # If the page size is 0, just return everything. + if page_size == 0: + headers = dict() + result = [self.serialize(instance, only=fields) + for instance in result] + # Otherwise, the page size is greater than zero, so paginate the + # response. + else: + page_number = int(request.args.get('page[number]', 1)) + if page_number < 0: + detail = 'Page number must be a positive integer' + return error_response(400, detail=detail) + # If the query is really a Flask-SQLAlchemy query, we can use + # the its built-in pagination. + if hasattr(result, 'paginate'): + pagination = result.paginate(page_number, page_size, + error_out=False) + first = 1 + last = pagination.pages + prev = pagination.prev_num + next_ = pagination.next_num + result = [self.serialize(instance, only=fields) + for instance in pagination.items] + else: + num_results = count(self.session, result) + first = 1 + # There will be no division-by-zero error here because we + # have already checked that page size is not equal to zero + # above. + last = int(math.ceil(num_results / page_size)) + prev = page_number - 1 if page_number > 1 else None + next_ = page_number + 1 if page_number < last else None + offset = (page_number - 1) * page_size + result = result.limit(page_size).offset(offset) + result = [self.serialize(instance, only=fields) + for instance in result] + # Create the pagination link URLs + # + # TODO pagination needs to respect sorting, fields, etc., so + # these link template strings are not quite right. + base_url = request.base_url + link_urls = (LINKTEMPLATE.format(base_url, num, page_size) + if num is not None else None + for rel, num in (('first', first), ('last', last), + ('prev', prev), ('next', next_))) + first_url, last_url, prev_url, next_url = link_urls + # Make them available for the result dictionary later. + pagination_links = dict(first=first_url, last=last_url, + prev=prev_url, next=next_url) + link_strings = ('<{0}>; rel="{1}"'.format(url, rel) + if url is not None else None + for rel, url in (('first', first_url), + ('last', last_url), + ('prev', prev_url), + ('next', next_url))) + # TODO Should this be multiple header fields, like this:: + # + # headers = [('Link', link) for link in link_strings + # if link is not None] + # + headers = dict(Link=','.join(link for link in link_strings + if link is not None)) + # Otherwise, the result of the search was a single resource. else: primary_key = self.primary_key or primary_key_name(result) - result = to_dict(result, deep, exclude=self.exclude_columns, - exclude_relations=self.exclude_relations, - include=self.include_columns, - include_relations=self.include_relations, - include_methods=self.include_methods) + result = self.serialize(result, only=fields) # The URL at which a client can access the instance matching this # search query. url = '{0}/{1}'.format(request.base_url, result[primary_key]) headers = dict(Location=url) + # Wrap the resulting object or list of objects under a `data` key. + result = dict(data=result) + + # Provide top-level links. + # + # TODO use a defaultdict for result, then cast it to a dict at the end. + if 'links' not in result: + result['links'] = dict() + result['links']['self'] = url_for(self.model) + result['links'].update(pagination_links) + for postprocessor in self.postprocessors['GET_MANY']: - postprocessor(result=result, search_params=search_params) + postprocessor(result=result, sort=sort) # HACK Provide the headers directly in the result dictionary, so that # the :func:`jsonpify` function has access to them. See the note there # for more information. - result[_HEADERS] = headers + result['meta'] = {_HEADERS: headers} return result, 200, headers - def get(self, instid, relationname, relationinstid): - """Returns a JSON representation of an instance of model with the - specified name. - - If ``instid`` is ``None``, this method returns the result of a search - with parameters specified in the query string of the request. If no - search parameters are specified, this method returns all instances of - the specified model. - - If ``instid`` is an integer, this method returns the instance of the - model with that identifying integer. If no such instance exists, this - method responds with :http:status:`404`. - - """ - if instid is None: - return self._search() + def _get_single(self, instid, relationname=None, relationinstid=None): for preprocessor in self.preprocessors['GET_SINGLE']: temp_result = preprocessor(instance_id=instid) # Let the return value of the preprocessor be the new value of @@ -1251,34 +1482,177 @@ def get(self, instid, relationname, relationinstid): # get the instance of the "main" model whose ID is instid instance = get_by(self.session, self.model, instid, self.primary_key) if instance is None: - return {_STATUS: 404}, 404 + message = 'No instance with ID {0}'.format(instid) + return error_response(404, detail=message) + # Get the fields to include for each type of object. + fields = parse_sparse_fields() + if self.collection_name in fields and self.default_fields is not None: + fields[self.collection_name] |= self.default_fields # If no relation is requested, just return the instance. Otherwise, # get the value of the relation specified by `relationname`. if relationname is None: - result = self.serialize(instance) + # Determine the fields to include for this object. + fields_for_this = fields.get(self.collection_name) + result = self.serialize(instance, only=fields_for_this) else: related_value = getattr(instance, relationname) # create a placeholder for the relations of the returned models related_model = get_related_model(self.model, relationname) - relations = frozenset(get_relations(related_model)) - deep = dict((r, {}) for r in relations) + # Determine fields to include for this model. + fields_for_this = fields.get(collection_name(related_model)) if relationinstid is not None: related_value_instance = get_by(self.session, related_model, relationinstid) if related_value_instance is None: return {_STATUS: 404}, 404 - result = to_dict(related_value_instance, deep) + result = self.serialize(related_value_instance, + fields_for_this) else: # for security purposes, don't transmit list as top-level JSON if is_like_list(instance, relationname): - result = self._paginated(list(related_value), deep) + # TODO Disabled pagination for now in order to ease + # transition into JSON API compliance. + # + # result = self._paginated(list(related_value), deep) + # + result = [self.serialize(inst, only=fields_for_this) + for inst in related_value] else: - result = to_dict(related_value, deep) + result = self.serialize(related_value, fields_for_this) if result is None: return {_STATUS: 404}, 404 + # Wrap the result + result = dict(data=result) + # Add any links requested to be included by URL parameters. + ids_to_link = self.links_to_add(result) + for link, linkids in ids_to_link.items(): + related_model = model_for(link) + related_instances = (get_by(self.session, related_model, + link_id) for link_id in linkids) + # Determine which fields to include in the linked objects. + fields_for_this = fields.get(link) + result['linked'].extend(self.serialize(x, only=fields_for_this) + for x in related_instances) for postprocessor in self.postprocessors['GET_SINGLE']: postprocessor(result=result) - return result + return result, 200 + + def links_to_add(self, result): + # Store the original data in a variable for easier access. + original = result['data'] + if isinstance(original, list): + has_links = any('links' in resource for resource in original) + else: + has_links = 'links' in original + if not has_links: + return {} + # Add any links requested to be included by URL parameters. + # + # We expect `toinclude` to be a comma-separated list of relationship + # paths. + toinclude = request.args.get('include') + if toinclude is None and self.default_includes is None: + return {} + elif toinclude is None and self.default_includes is not None: + toinclude = self.default_includes + elif toinclude is not None and self.default_includes is None: + toinclude = set(toinclude.split(',')) + else: # toinclude is not None and self.default_includes is not None: + toinclude = set(toinclude.split(',')) | self.default_includes + ids_to_link = defaultdict(set) + result['linked'] = [] + # TODO we should reverse the nested-ness of these for loops: + # toinclude is likely to be a small list, and `original` could be a + # very large list, so the latter should be the outer loop. + for link in toinclude: + # TODO deal with dot-separated lists. + # + # If there is a list of instances, collect all the linked IDs + # of the appropriate type. + if isinstance(original, list): + for resource in original: + # If the resource has a link with the name specified in + # `toinclude`, then get the type and IDs of that link. + if link in resource['links']: + link_object = resource['links'][link] + link_type = link_object['type'] + if 'ids' in link_object: + ids_to_link[link_type] |= \ + set(link_object['ids']) + elif 'id' in resource[link]: + ids_to_link[link_type].add(link_object['id']) + else: + # TODO Raise an error here. + pass + # Otherwise, if there is just a single instance, look through + # the links to get the IDs of the linked instances. + else: + # If the resource has a link with the name specified in + # `toinclude`, then get the type and IDs of that link. + if link in original['links']: + link_object = original['links'][link] + link_type = link_object['type'] + if 'ids' in link_object: + ids_to_link[link_type] |= set(link_object['ids']) + elif 'id' in link_object: + ids_to_link[link_type].add(link_object['id']) + else: + # TODO Raise an error here. + pass + return ids_to_link + + def _get_many(self, *ids): + # Each call to _get_single returns a two-tuple whose left element is + # the dictionary to be converted into JSON and whose right element is + # the status code. + result = [self._get_single(instid) for instid in ids] + # If any of the instances was not found, return a 404 for the whole + # request. + if any(status == 404 for data, status in result): + return {_STATUS: 404}, 404 + # HACK This should really not be necessary. + # + # Collect all the instances into a single list and wrap the collection + # with the collection name. + collection = [data[self.collection_name] for data, status in result] + return {self.collection_name: collection}, 200 + + def get(self, instid, relationname, relationinstid): + """Returns a JSON representation of an instance of model with the + specified name. + + If ``instid`` is ``None``, this method returns the result of a search + with parameters specified in the query string of the request. If no + search parameters are specified, this method returns all instances of + the specified model. + + If ``instid`` is an integer, this method returns the instance of the + model with that identifying integer. If no such instance exists, this + method responds with :http:status:`404`. + + """ + content_type = request.headers.get('Content-Type', None) + content_is_json = content_type.startswith(CONTENT_TYPE) + is_msie = _is_msie8or9() + # Request must have the Content-Type: application/vnd.api+json header, + # unless the User-Agent string indicates that the client is Microsoft + # Internet Explorer 8 or 9 (which has a fixed Content-Type of + # 'text/html'; see issue #267). + if not is_msie and not content_is_json: + detail = ('Request must have "Content-Type: {0}"' + ' header').format(CONTENT_TYPE) + return error_response(415, detail=detail) + if instid is None: + return self._search() + # HACK-ish: If we don't do this, Flask gets confused and routes GET + # requests of the form `/api/computers/1/links/owner` here. This is + # because the RelationshipAPI class doesn't allow GET methods. + if relationname == 'links': + detail = ('This server does not allow GET requests to relationship' + ' URLs; maybe you meant to send a request to the related' + ' resource URL instead?') + return error_response(403, detail=detail) + return self._get_single(instid, relationname, relationinstid) def _delete_many(self): """Deletes multiple instances of the model. @@ -1293,13 +1667,13 @@ def _delete_many(self): """ # try to get search query from the request query parameters try: - search_params = json.loads(request.args.get('q', '{}')) + filters = json.loads(request.args.get('filter[objects]', '[]')) except (TypeError, ValueError, OverflowError) as exception: current_app.logger.exception(str(exception)) return dict(message='Unable to decode search query'), 400 for preprocessor in self.preprocessors['DELETE_MANY']: - preprocessor(search_params=search_params) + preprocessor(filters=filters) # perform a filtered search try: @@ -1311,7 +1685,7 @@ def _delete_many(self): # sqlalchemy.exc.InvalidRequestError: Can't call Query.delete() # when order_by() has been called # - result = search(self.session, self.model, search_params, + result = search(self.session, self.model, filters, _ignore_order_by=True) except NoResultFound: return dict(message='No result found'), 404 @@ -1334,10 +1708,9 @@ def _delete_many(self): self.session.delete(result) num_deleted = 1 self.session.commit() - result = dict(num_deleted=num_deleted) for postprocessor in self.postprocessors['DELETE_MANY']: - postprocessor(result=result, search_params=search_params) - return (result, 200) if num_deleted > 0 else 404 + postprocessor(search_params=search_params, num_deleted=num_deleted) + return {}, 204 def delete(self, instid, relationname, relationinstid): """Removes the specified instance of the model with the specified name @@ -1358,10 +1731,23 @@ def delete(self, instid, relationname, relationinstid): Added the `relationname` keyword argument. """ + content_type = request.headers.get('Content-Type', None) + content_is_json = content_type.startswith(CONTENT_TYPE) + is_msie = _is_msie8or9() + # Request must have the Content-Type: application/vnd.api+json header, + # unless the User-Agent string indicates that the client is Microsoft + # Internet Explorer 8 or 9 (which has a fixed Content-Type of + # 'text/html'; see issue #267). + if not is_msie and not content_is_json: + detail = ('Request must have "Content-Type: {0}"' + ' header').format(CONTENT_TYPE) + return error_response(415, detail=detail) + # If no instance ID is provided, this request is an attempt to delete + # many instances of the model, possibly filtered. if instid is None: - # If no instance ID is provided, this request is an attempt to - # delete many instances of the model via a search with possible - # filters. + if not self.allow_delete_many: + detail = 'Server does not allow deleting from a collection' + return error_response(405, detail=detail) return self._delete_many() was_deleted = False for preprocessor in self.preprocessors['DELETE_SINGLE']: @@ -1371,30 +1757,93 @@ def delete(self, instid, relationname, relationinstid): # See the note under the preprocessor in the get() method. if temp_result is not None: instid = temp_result - inst = get_by(self.session, self.model, instid, self.primary_key) - if relationname: - # If the request is ``DELETE /api/person/1/computers``, error 400. - if not relationinstid: - msg = ('Cannot DELETE entire "{0}"' - ' relation').format(relationname) - return dict(message=msg), 400 - # Otherwise, get the related instance to delete. - relation = getattr(inst, relationname) - related_model = get_related_model(self.model, relationname) - relation_instance = get_by(self.session, related_model, - relationinstid) - # Removes an object from the relation list. - relation.remove(relation_instance) + if ',' in instid: + ids = instid.split(',') + inst = [get_by(self.session, self.model, id_, self.primary_key) + for id_ in ids] + else: + inst = get_by(self.session, self.model, instid, self.primary_key) + if relationname is not None: + # If no such relation exists, return an error to the client. + if not hasattr(inst, relationname): + msg = 'No such link: {0}'.format(relationname) + return dict(message=msg), 404 + # If this is a delete of a one-to-many relationship, remove the + # related instance. + if relationinstid is not None: + related_model = get_related_model(self.model, relationname) + relation = getattr(inst, relationname) + if ',' in relationinstid: + ids = relationinstid.split(',') + else: + ids = [relationinstid] + toremove = (get_by(self.session, related_model, id_) for id_ in + ids) + for obj in toremove: + relation.remove(obj) + else: + # If there is no link there to delete, return an error. + if getattr(inst, relationname) is None: + detail = ('No linked instance to delete:' + ' {0}').format(relationname) + return error_response(400, detail=detail) + # TODO this doesn't apply to a many-to-one endpoint applies + # + # if not relationinstid: + # msg = ('Cannot DELETE entire "{0}"' + # ' relation').format(relationname) + # return dict(message=msg), 400 + # + # Otherwise, remove the related instance. + setattr(inst, relationname, None) was_deleted = len(self.session.dirty) > 0 elif inst is not None: - self.session.delete(inst) + if not isinstance(inst, list): + inst = [inst] + for instance in inst: + self.session.delete(instance) was_deleted = len(self.session.deleted) > 0 self.session.commit() for postprocessor in self.postprocessors['DELETE_SINGLE']: postprocessor(was_deleted=was_deleted) return {}, 204 if was_deleted else 404 - def post(self): + # def _create_single(self, data): + # # Getting the list of relations that will be added later + # cols = get_columns(self.model) + # relations = set(get_relations(self.model)) + # # Looking for what we're going to set on the model right now + # colkeys = set(cols.keys()) + # fields = set(data.keys()) + # props = (colkeys & fields) - relations + # # Instantiate the model with the parameters. + # modelargs = dict([(i, data[i]) for i in props]) + # instance = self.model(**modelargs) + # # Handling relations, a single level is allowed + # for col in relations & fields: + # submodel = get_related_model(self.model, col) + + # if type(data[col]) == list: + # # model has several related objects + # for subparams in data[col]: + # subinst = get_or_create(self.session, submodel, + # subparams) + # try: + # getattr(instance, col).append(subinst) + # except AttributeError: + # attribute = getattr(instance, col) + # attribute[subinst.key] = subinst.value + # else: + # # model has single related object + # subinst = get_or_create(self.session, submodel, + # data[col]) + # setattr(instance, col, subinst) + + # # add the created model to the session + # self.session.add(instance) + # return instance + + def post(self, instid, relationname, relationinstid): """Creates a new instance of a given model based on request data. This function parses the string contained in @@ -1415,15 +1864,16 @@ def post(self): """ content_type = request.headers.get('Content-Type', None) - content_is_json = content_type.startswith('application/json') + content_is_json = content_type.startswith(CONTENT_TYPE) is_msie = _is_msie8or9() - # Request must have the Content-Type: application/json header, unless - # the User-Agent string indicates that the client is Microsoft Internet - # Explorer 8 or 9 (which has a fixed Content-Type of 'text/html'; see - # issue #267). + # Request must have the Content-Type: application/vnd.api+json header, + # unless the User-Agent string indicates that the client is Microsoft + # Internet Explorer 8 or 9 (which has a fixed Content-Type of + # 'text/html'; see issue #267). if not is_msie and not content_is_json: - msg = 'Request must have "Content-Type: application/json" header' - return dict(message=msg), 415 + detail = ('Request must have "Content-Type: {0}"' + ' header').format(CONTENT_TYPE) + return error_response(415, detail=detail) # try to read the parameters for the model from the body of the request try: @@ -1432,6 +1882,10 @@ def post(self): if is_msie: data = json.loads(request.get_data()) or {} else: + # This doesn't work in versions of Flask less than 1.0 because + # in those versions, get_json() only works if the request + # content type is application/json; we require the content type + # specified in :data:`CONTENT_TYPE`. data = request.get_json() or {} except (BadRequest, TypeError, ValueError, OverflowError) as exception: current_app.logger.exception(str(exception)) @@ -1441,36 +1895,214 @@ def post(self): for preprocessor in self.preprocessors['POST']: preprocessor(data=data) + # Check if this is a request to update a relation. + if (instid is not None and relationname is not None + and relationinstid is None): + # Get the instance on which to set the relationship info. + instance = get_by(self.session, self.model, instid) + # If no such relation exists, return an error to the client. + if not hasattr(instance, relationname): + msg = 'No such link: {0}'.format(relationname) + return dict(message=msg), 404 + related_model = get_related_model(self.model, relationname) + relation = getattr(instance, relationname) + # If it is -to-many relation, add to the existing list. + if is_like_list(instance, relationname): + related_id = data.pop(relationname) + if isinstance(related_id, list): + related_instances = [get_by(self.session, related_model, + d) for d in related_id] + else: + related_instances = [get_by(self.session, related_model, + related_id)] + relation.extend(related_instances) + # Otherwise it is a -to-one relation. + else: + # If there is already something there, return an error. + if relation is not None: + msg = ('Cannot POST to a -to-one relationship that already' + ' has a linked instance (with ID' + ' {0})').format(relationinstid) + return dict(message=msg), 400 + # Get the ID of the related model to which to set the link. + # + # TODO I don't know the collection name for the linked objects, + # so I can't provide a correctly named mapping here. + # + # related_id = data[collection_name(related_model)] + related_id = data.popitem()[1] + related_instance = get_by(self.session, related_model, + related_id) + try: + setattr(instance, relationname, related_instance) + except self.validation_exceptions as exception: + current_app.logger.exception(str(exception)) + return self._handle_validation_exception(exception) + result = {} + status = 204 + headers = {} + else: + if 'data' not in data: + detail = 'Resource must have a "data" key' + return error_response(400, detail=detail) + data = data['data'] + has_many = isinstance(data, list) + try: + # Convert the dictionary representation into an instance of the + # model. + if has_many: + instances = [self.deserialize(obj) for obj in data] + # Add the created model to the session. + self.session.add_all(instances) + else: + if 'type' not in data: + detail = 'Must specify correct data type' + return error_response(400, detail=detail) + if 'id' in data and not self.allow_client_generated_ids: + detail = 'Server does not allow client-generated IDS' + return error_response(403, detail=detail) + type_ = data.pop('type') + if type_ != self.collection_name: + message = ('Type must be {0}, not' + ' {1}').format(self.collection_name, type_) + return error_response(409, detail=message) + instance = self.deserialize(data) + self.session.add(instance) + self.session.commit() + # Get the dictionary representation of the new instance as it + # appears in the database. + if has_many: + result = [self.serialize(inst) for inst in instances] + else: + result = self.serialize(instance) + except self.validation_exceptions as exception: + return self._handle_validation_exception(exception) + # Determine the value of the primary key for this instance and + # encode URL-encode it (in case it is a Unicode string). + if has_many: + primary_keys = [primary_key_value(inst, as_string=True) + for inst in instances] + else: + primary_key = primary_key_value(instance, as_string=True) + # The URL at which a client can access the newly created instance + # of the model. + if has_many: + urls = ['{0}/{1}'.format(request.base_url, k) + for k in primary_keys] + else: + url = '{0}/{1}'.format(request.base_url, primary_key) + # Provide that URL in the Location header in the response. + # + # TODO should the many Location header fields be combined into a + # single comma-separated header field:: + # + # headers = dict(Location=', '.join(urls)) + # + if has_many: + headers = (('Location', url) for url in urls) + else: + headers = dict(Location=url) + # Wrap the resulting object or list of objects under a 'data' key. + result = dict(data=result) + status = 201 + for postprocessor in self.postprocessors['POST']: + postprocessor(result=result) + return result, status, headers + + def _update_single(self, instance, data): + # Update any relationships. + links = data.pop('links', {}) + for linkname, link in links.items(): + related_model = get_related_model(self.model, linkname) + # If the client provided "null" for this relation, remove it by + # setting the attribute to ``None``. + if link is None: + setattr(instance, linkname, None) + continue + # TODO check for conflicting or missing types here + # type_ = link['type'] + + # If this is a to-one relationship, just get the single related + # resource. If it is a to-many relationship, get all the related + # resources. + if 'id' in link: + newvalue = get_by(self.session, related_model, link['id']) + elif 'ids' in link: + # Replacement of a to-many relationship may have been disabled + # by the user. + if not self.allow_to_many_replacement: + message = 'Not allowed to replace a to-many relationship' + return error_response(403, detail=message) + newvalue = [get_by(self.session, related_model, related_id) + for related_id in link['ids']] + else: + # TODO raise error here for missing id or ids + pass + # If the to-one relationship resource or any of the to-many + # relationship resources do not exist, return an error response. + if newvalue is None: + detail = ('No object of type {0} found' + ' with ID {1}').format(link['type'], link['id']) + return error_response(404, detail=detail) + elif isinstance(newvalue, list) and any(value is None + for value in newvalue): + not_found = (id_ for id_, value in zip(link['ids'], newvalue) + if value is None) + msg = 'No object of type {0} found with ID {1}' + errors = [error(detail=msg.format(link['type'], id_)) + for id_ in not_found] + return errors_response(404, errors) + try: + setattr(instance, linkname, newvalue) + except self.validation_exceptions as exception: + current_app.logger.exception(str(exception)) + return self._handle_validation_exception(exception) + + # Check for any request parameter naming a column which does not exist + # on the current model. + # + # Incoming data could be a list or a single resource representation. + if isinstance(data, list): + fields = set(chain(data)) + else: + fields = data.keys() + for field in fields: + if not has_field(self.model, field): + msg = "Model does not have field '{0}'".format(field) + return dict(message=msg), 400 + + # if putmany: + # try: + # # create a SQLALchemy Query from the query parameter `q` + # query = create_query(self.session, self.model, search_params) + # except Exception as exception: + # current_app.logger.exception(str(exception)) + # return dict(message='Unable to construct query'), 400 + # else: + for link, value in data.pop('links', {}).items(): + related_model = get_related_model(self.model, link) + related_instance = get_by(self.session, related_model, value) + try: + setattr(instance, link, related_instance) + except self.validation_exceptions as exception: + current_app.logger.exception(str(exception)) + return self._handle_validation_exception(exception) + # Special case: if there are any dates, convert the string form of the + # date into an instance of the Python ``datetime`` object. + data = strings_to_datetimes(self.model, data) + # Try to update all instances present in the query. + num_modified = 0 try: - # Convert the dictionary representation into an instance of the - # model. - instance = self.deserialize(data) - # Add the created model to the session. - self.session.add(instance) + if data: + for field, value in data.items(): + setattr(instance, field, value) + num_modified += 1 self.session.commit() - # Get the dictionary representation of the new instance as it - # appears in the database. - result = self.serialize(instance) except self.validation_exceptions as exception: + current_app.logger.exception(str(exception)) return self._handle_validation_exception(exception) - # Determine the value of the primary key for this instance and - # encode URL-encode it (in case it is a Unicode string). - pk_name = self.primary_key or primary_key_name(instance) - primary_key = result[pk_name] - try: - primary_key = str(primary_key) - except UnicodeEncodeError: - primary_key = url_quote_plus(primary_key.encode('utf-8')) - # The URL at which a client can access the newly created instance - # of the model. - url = '{0}/{1}'.format(request.base_url, primary_key) - # Provide that URL in the Location header in the response. - headers = dict(Location=url) - for postprocessor in self.postprocessors['POST']: - postprocessor(result=result) - return result, 201, headers - def patch(self, instid, relationname, relationinstid): + def put(self, instid, relationname, relationinstid): """Updates the instance specified by ``instid`` of the named model, or updates multiple instances if ``instid`` is ``None``. @@ -1496,14 +2128,15 @@ def patch(self, instid, relationname, relationinstid): """ content_type = request.headers.get('Content-Type', None) - content_is_json = content_type.startswith('application/json') + content_is_json = content_type.startswith(CONTENT_TYPE) is_msie = _is_msie8or9() - # Request must have the Content-Type: application/json header, unless - # the User-Agent string indicates that the client is Microsoft Internet - # Explorer 8 or 9 (which has a fixed Content-Type of 'text/html'; see - # issue #267). + # Request must have the Content-Type: application/vnd.api+json header, + # unless the User-Agent string indicates that the client is Microsoft + # Internet Explorer 8 or 9 (which has a fixed Content-Type of + # 'text/html'; see issue #267). if not is_msie and not content_is_json: - msg = 'Request must have "Content-Type: application/json" header' + msg = ('Request must have "Content-Type: {0}"' + ' header').format(CONTENT_TYPE) return dict(message=msg), 415 # try to load the fields/values to update from the body of the request @@ -1518,83 +2151,362 @@ def patch(self, instid, relationname, relationinstid): # this also happens when request.data is empty current_app.logger.exception(str(exception)) return dict(message='Unable to decode data'), 400 - - # Check if the request is to patch many instances of the current model. - patchmany = instid is None - # Perform any necessary preprocessing. - if patchmany: - # Get the search parameters; all other keys in the `data` - # dictionary indicate a change in the model's field. - search_params = data.pop('q', {}) - for preprocessor in self.preprocessors['PATCH_MANY']: - preprocessor(search_params=search_params, data=data) - else: - for preprocessor in self.preprocessors['PATCH_SINGLE']: - temp_result = preprocessor(instance_id=instid, data=data) - # See the note under the preprocessor in the get() method. - if temp_result is not None: - instid = temp_result - - # Check for any request parameter naming a column which does not exist - # on the current model. - for field in data: - if not has_field(self.model, field): - msg = "Model does not have field '{0}'".format(field) - return dict(message=msg), 400 - - if patchmany: + for preprocessor in self.preprocessors['PUT_SINGLE']: + temp_result = preprocessor(instance_id=instid, data=data) + # See the note under the preprocessor in the get() method. + if temp_result is not None: + instid = temp_result + # Get the instance on which to set the new attributes. + instance = get_by(self.session, self.model, instid, self.primary_key) + # If no instance of the model exists with the specified instance ID, + # return a 404 response. + if instance is None: + detail = 'No instance with ID {0} in model {1}'.format(instid, + self.model) + return error_response(404, detail=detail) + # Check if this is a request to update a relation. + if (instid is not None and relationname is not None + and relationinstid is None): + related_model = get_related_model(self.model, relationname) + # Get the ID of the related model to which to set the link. + # + # TODO I don't know the collection name for the linked objects, so + # I can't provide a correctly named mapping here. + # + # related_id = data[collection_name(related_model)] + related_id = data.popitem()[1] + if isinstance(related_id, list): + related_instance = [get_by(self.session, related_model, d) + for d in related_id] + else: + related_instance = get_by(self.session, related_model, + related_id) try: - # create a SQLALchemy Query from the query parameter `q` - query = create_query(self.session, self.model, search_params) - except Exception as exception: + setattr(instance, relationname, related_instance) + except self.validation_exceptions as exception: current_app.logger.exception(str(exception)) - return dict(message='Unable to construct query'), 400 + return self._handle_validation_exception(exception) + # This is a request to update an instance of the model. else: - # create a SQLAlchemy Query which has exactly the specified row - query = query_by_primary_key(self.session, self.model, instid, - self.primary_key) - if query.count() == 0: - return {_STATUS: 404}, 404 - assert query.count() == 1, 'Multiple rows with same ID' + # Unwrap the data from the collection name key. + data = data.pop('data', {}) + if 'type' not in data: + message = 'Must specify correct data type' + return error_response(400, detail=message) + if 'id' not in data: + message = 'Must specify resource ID' + return error_response(400, detail=message) + type_ = data.pop('type') + id_ = data.pop('id') + if type_ != self.collection_name: + message = ('Type must be {0}, not' + ' {1}').format(self.collection_name, type_) + return error_response(409, detail=message) + if id_ != instid: + message = 'ID must be {0}, not {1}'.format(instid, id_) + return error_response(409, detail=message) + # If we are attempting to update multiple objects. + # if isinstance(data, list): + # # Check that the IDs specified in the body of the request + # # match the IDs specified in the URL. + # if not all('id' in d and str(d['id']) in ids for d in data): + # msg = 'IDs in body of request must match IDs in URL' + # return dict(message=msg), 400 + # for newdata in data: + # instance = get_by(self.session, self.model, + # newdata['id'], self.primary_key) + # self._update_single(instance, newdata) + # else: + # instance = get_by(self.session, self.model, instid, + # self.primary_key) + result = self._update_single(instance, data) + # If result is not None, that means there was an error updating the + # resource. + if result is not None: + return result + # Perform any necessary postprocessing. + for postprocessor in self.postprocessors['PUT_SINGLE']: + postprocessor() + return {}, 204 - try: - relations = self._update_relations(query, data) - except self.validation_exceptions as exception: - current_app.logger.exception(str(exception)) - return self._handle_validation_exception(exception) - field_list = frozenset(data) ^ relations - data = dict((field, data[field]) for field in field_list) - # Special case: if there are any dates, convert the string form of the - # date into an instance of the Python ``datetime`` object. - data = strings_to_dates(self.model, data) +class RelationshipAPI(APIBase): + + def __init__(self, *args, allow_delete_from_to_many_relationships=False, + **kw): + super(RelationshipAPI, self).__init__(*args, **kw) + self.allow_delete_from_to_many_relationships = \ + allow_delete_from_to_many_relationships + + def post(self, instid, relationname): + # Request must have the Content-Type: application/vnd.api+json header, + # unless the User-Agent string indicates that the client is Microsoft + # Internet Explorer 8 or 9 (which has a fixed Content-Type of + # 'text/html'; see issue #267). + content_type = request.headers.get('Content-Type', None) + content_is_json = content_type.startswith(CONTENT_TYPE) + is_msie = _is_msie8or9() + if not is_msie and not content_is_json: + detail = ('Request must have "Content-Type: {0}"' + ' header').format(CONTENT_TYPE) + return error_response(415, detail=detail) + # try to load the fields/values to update from the body of the request try: - # Let's update all instances present in the query - num_modified = 0 - if data: - for item in query.all(): - for field, value in data.items(): - setattr(item, field, value) - num_modified += 1 - self.session.commit() + # HACK Requests made from Internet Explorer 8 or 9 don't have the + # correct content type, so request.get_json() doesn't work. + if is_msie: + data = json.loads(request.get_data()) or {} + else: + data = request.get_json() or {} + except (BadRequest, TypeError, ValueError, OverflowError) as exception: + # this also happens when request.data is empty + current_app.logger.exception(str(exception)) + return error_response(400, detail='Unable to decode data') + for preprocessor in self.preprocessors['POST']: + temp_result = preprocessor(instance_id=instid, + relation_name=relationname, data=data) + # See the note under the preprocessor in the get() method. + if temp_result is not None: + instid, relationname = temp_result + instance = get_by(self.session, self.model, instid, self.primary_key) + # If no instance of the model exists with the specified instance ID, + # return a 404 response. + if instance is None: + detail = 'No instance with ID {0} in model {1}'.format(instid, + self.model) + return error_response(404, detail=detail) + # If no such relation exists, return a 404. + if not hasattr(instance, relationname): + detail = 'Model {0} has no relation named {1}'.format(self.model, + relationname) + return error_response(404, detail=detail) + related_model = get_related_model(self.model, relationname) + related_value = getattr(instance, relationname) + # Unwrap the data from the request. + data = data.pop('data', {}) + if 'type' not in data: + detail = 'Must specify correct data type' + return error_response(400, detail=detail) + if 'ids' not in data: + detail = 'Must specify resource IDs' + return error_response(400, detail=detail) + type_ = data.pop('type') + # The type name must match the collection name of model of the + # relation. + if type_ != collection_name(related_model): + detail = ('Type must be {0}, not' + ' {1}').format(collection_name(related_model), type_) + return error_response(409, detail=detail) + ids = data.pop('ids') + # Get the new objects to add to the relation. + new_values = set(get_by(self.session, related_model, id_) + for id_ in ids) + not_found = [id_ for id_, value in zip(ids, new_values) + if value is None] + if not_found: + msg = 'No object of type {0} found with ID {1}' + errors = [error(detail=msg.format(type_, id_)) + for id_ in not_found] + return errors_response(404, errors) + try: + for new_value in new_values: + # Don't append a new value if it already exists in the to-many + # relationship. + if new_value not in related_value: + related_value.append(new_value) except self.validation_exceptions as exception: current_app.logger.exception(str(exception)) return self._handle_validation_exception(exception) - + # TODO do we need to commit the session here? + # + # self.session.commit() + # # Perform any necessary postprocessing. - if patchmany: - result = dict(num_modified=num_modified) - for postprocessor in self.postprocessors['PATCH_MANY']: - postprocessor(query=query, result=result, - search_params=search_params) + for postprocessor in self.postprocessors['POST']: + postprocessor() + return {}, 204 + + def put(self, instid, relationname): + # Request must have the Content-Type: application/vnd.api+json header, + # unless the User-Agent string indicates that the client is Microsoft + # Internet Explorer 8 or 9 (which has a fixed Content-Type of + # 'text/html'; see issue #267). + content_type = request.headers.get('Content-Type', None) + content_is_json = content_type.startswith(CONTENT_TYPE) + is_msie = _is_msie8or9() + if not is_msie and not content_is_json: + detail = ('Request must have "Content-Type: {0}"' + ' header').format(CONTENT_TYPE) + return error_response(415, detail=detail) + + # try to load the fields/values to update from the body of the request + try: + # HACK Requests made from Internet Explorer 8 or 9 don't have the + # correct content type, so request.get_json() doesn't work. + if is_msie: + data = json.loads(request.get_data()) or {} + else: + data = request.get_json() or {} + except (BadRequest, TypeError, ValueError, OverflowError) as exception: + # this also happens when request.data is empty + current_app.logger.exception(str(exception)) + return error_response(400, detail='Unable to decode data') + for preprocessor in self.preprocessors['PUT_SINGLE']: + temp_result = preprocessor(instance_id=instid, + relation_name=relationname, data=data) + # See the note under the preprocessor in the get() method. + if temp_result is not None: + instid, relationname = temp_result + instance = get_by(self.session, self.model, instid, self.primary_key) + # If no instance of the model exists with the specified instance ID, + # return a 404 response. + if instance is None: + detail = 'No instance with ID {0} in model {1}'.format(instid, + self.model) + return error_response(404, detail=detail) + # If no such relation exists, return a 404. + if not hasattr(instance, relationname): + detail = 'Model {0} has no relation named {1}'.format(self.model, + relationname) + return error_response(404, detail=detail) + related_model = get_related_model(self.model, relationname) + # related_value = getattr(instance, relationname) + + # Unwrap the data from the request. + data = data.pop('data', {}) + # If the client sent a null value, we assume it wants to remove a + # to-one relationship. + if data is None: + # TODO check that the relationship is a to-one relationship. + setattr(instance, relationname, None) else: - result = self._instid_to_dict(instid) - for postprocessor in self.postprocessors['PATCH_SINGLE']: - postprocessor(result=result) + if 'type' not in data: + detail = 'Must specify correct data type' + return error_response(400, detail=detail) + if 'id' not in data and 'ids' not in data: + detail = 'Must specify resource ID or IDs' + return error_response(400, detail=detail) + type_ = data.pop('type') + # The type name must match the collection name of model of the + # relation. + if type_ != collection_name(related_model): + detail = ('Type must be {0}, not' + ' {1}').format(collection_name(related_model), type_) + return error_response(409, detail=detail) + # If there is just an 'id' key, we assume the client is trying to + # set a to-one relationship. + if 'id' in data: + id_ = data.pop('id') + # The new value with which to replace the current related value + replacement = get_by(self.session, related_model, id_) + # If there is an 'ids' key, we assume the client is trying to set a + # to-many relationship. + elif 'ids' in data: + # Replacement of a to-many relationship may have been disabled + # by the user. + if not self.allow_to_many_replacement: + message = 'Not allowed to replace a to-many relationship' + return error_response(403, detail=message) + ids = data.pop('ids') + # The new value with which to replace the current related value + replacement = [get_by(self.session, related_model, id_) + for id_ in ids] + else: + # TODO raise an error + pass + # If the to-one relationship resource or any of the to-many + # relationship resources do not exist, return an error response. + if replacement is None: + detail = ('No object of type {0} found' + ' with ID {1}').format(type_, id_) + return error_response(404, detail=detail) + if (isinstance(replacement, list) + and any(value is None for value in replacement)): + not_found = (id_ for id_, value in zip(ids, replacement) + if value is None) + msg = 'No object of type {0} found with ID {1}' + errors = [error(detail=msg.format(type_, id_)) + for id_ in not_found] + return errors_response(404, errors) + try: + setattr(instance, relationname, replacement) + except self.validation_exceptions as exception: + current_app.logger.exception(str(exception)) + return self._handle_validation_exception(exception) + # TODO do we need to commit the session here? + # + # self.session.commit() + # + # Perform any necessary postprocessing. + for postprocessor in self.postprocessors['PUT']: + postprocessor() + return {}, 204 + + def delete(self, instid, relationname): + # Request must have the Content-Type: application/vnd.api+json header, + # unless the User-Agent string indicates that the client is Microsoft + # Internet Explorer 8 or 9 (which has a fixed Content-Type of + # 'text/html'; see issue #267). + content_type = request.headers.get('Content-Type', None) + content_is_json = content_type.startswith(CONTENT_TYPE) + is_msie = _is_msie8or9() + if not is_msie and not content_is_json: + detail = ('Request must have "Content-Type: {0}"' + ' header').format(CONTENT_TYPE) + return error_response(415, detail=detail) - return result + if not self.allow_delete_from_to_many_relationships: + detail = 'Not allowed to delete from a to-many relationship' + return error_response(403, detail=detail) - def put(self, *args, **kw): - """Alias for :meth:`patch`.""" - return self.patch(*args, **kw) + # try to load the fields/values to update from the body of the request + try: + # HACK Requests made from Internet Explorer 8 or 9 don't have the + # correct content type, so request.get_json() doesn't work. + if is_msie: + data = json.loads(request.get_data()) or {} + else: + data = request.get_json() or {} + except (BadRequest, TypeError, ValueError, OverflowError) as exception: + # this also happens when request.data is empty + current_app.logger.exception(str(exception)) + return error_response(400, detail='Unable to decode data') + was_deleted = False + for preprocessor in self.preprocessors['DELETE']: + temp_result = preprocessor(instance_id=instid, + relation_name=relationname) + # See the note under the preprocessor in the get() method. + if temp_result is not None: + instid = temp_result + instance = get_by(self.session, self.model, instid, self.primary_key) + # If no such relation exists, return an error to the client. + if not hasattr(instance, relationname): + detail = 'No such link: {0}'.format(relationname) + return error_response(404, detail=detail) + # We assume that the relation is a to-many relation. + related_model = get_related_model(self.model, relationname) + relation = getattr(instance, relationname) + data = data.pop('data') + if 'type' not in data: + detail = 'Must specify correct data type' + return error_response(400, detail=detail) + if 'ids' not in data: + detail = 'Must specify resource IDs' + return error_response(400, detail=detail) + # type_ = data['type'] + ids = data['ids'] + toremove = set(get_by(self.session, related_model, id_) for id_ in ids) + for obj in toremove: + try: + relation.remove(obj) + except ValueError: + # The JSON API specification requires that we silently ignore + # requests to delete nonexistent objects from a to-many + # relation. + pass + was_deleted = len(self.session.dirty) > 0 + self.session.commit() + for postprocessor in self.postprocessors['DELETE']: + postprocessor(was_deleted=was_deleted) + return {}, 204 if was_deleted else 404 diff --git a/requirements.txt b/requirements.txt index 60fc8ff5..3d5f758d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -flask>=0.10 +flask>=1.0 flask-sqlalchemy sqlalchemy>=0.8 python-dateutil>2.0 diff --git a/setup.py b/setup.py index 08379bb2..3c9e016d 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ #: The installation requirements for Flask-Restless. Flask-SQLAlchemy is not #: required, so the user must install it explicitly. -requirements = ['flask>=0.10', 'sqlalchemy>=0.8', 'python-dateutil>2.0', +requirements = ['flask>=1.0', 'sqlalchemy>=0.8', 'python-dateutil>2.0', 'mimerender>=0.5.2'] diff --git a/tests/helpers.py b/tests/helpers.py index 7e16fcab..ef1f8db5 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -26,6 +26,7 @@ def isclass(obj): import uuid from flask import Flask +from flask import json from nose import SkipTest from sqlalchemy import Boolean from sqlalchemy import Column @@ -57,8 +58,21 @@ def isclass(obj): except ImportError: flask_sa = None - from flask.ext.restless import APIManager +from flask.ext.restless import CONTENT_TYPE + +dumps = json.dumps +loads = json.loads + +#: The User-Agent string for Microsoft Internet Explorer 8. +#: +#: From . +MSIE8_UA = 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)' + +#: The User-Agent string for Microsoft Internet Explorer 9. +#: +#: From . +MSIE9_UA = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)' def skip_unless(condition, reason=None): @@ -90,6 +104,15 @@ def inner(*args, **kw): return skip +def skip(reason=None): + """Unconditionally skip a test. + + This is a convenience function for ``skip_unless(False, reason)``. + + """ + return skip_unless(False, reason) + + def unregister_fsa_session_signals(): """ When Flask-SQLAlchemy object is created, it registers some @@ -114,26 +137,38 @@ def unregister_fsa_session_signals(): def force_json_contenttype(test_client): """Ensures that all requests made by the specified Flask test client have - the ``Content-Type`` header set to ``application/json``, unless another - content type is explicitly specified. + the correct ``Content-Type`` header. + + For :http:method:`patch` requests, this means + ``application/json-patch+json``. For all other requests, the content type + is set to ``application/vnd.api+json``, unless another content type is + explicitly specified at the time the method is invoked. """ - for methodname in ('get', 'put', 'patch', 'post', 'delete'): - # Create a decorator for the test client request methods that adds - # a JSON Content-Type by default if none is specified. - def set_content_type(func): - def new_func(*args, **kw): - if 'content_type' not in kw: - kw['content_type'] = 'application/json' - return func(*args, **kw) - return new_func + # Create a decorator for the test client request methods that adds + # a JSON Content-Type by default if none is specified. + def set_content_type(func, headers=None, content_type=CONTENT_TYPE): + def new_func(*args, **kw): + if 'content_type' not in kw: + kw['content_type'] = content_type + if 'headers' not in kw: + kw['headers'] = dict() + if 'Accept' not in kw['headers']: + kw['headers']['Accept'] = CONTENT_TYPE + return func(*args, **kw) + return new_func + + for methodname in ('get', 'put', 'post', 'delete'): # Decorate the original test client request method. old_method = getattr(test_client, methodname) setattr(test_client, methodname, set_content_type(old_method)) + # PATCH methods need to have `application/json-patch+json` content type. + test_client.patch = set_content_type(test_client.patch, + 'application/json-patch+json') # This code adapted from -# http://docs.sqlalchemy.org/en/rel_0_8/core/types.html#backend-agnostic-guid-type +# http://docs.sqlalchemy.org/en/latest/core/custom_types.html#backend-agnostic-guid-type class GUID(TypeDecorator): """Platform-independent GUID type. @@ -153,9 +188,9 @@ def process_bind_param(self, value, dialect): if dialect.name == 'postgresql': return str(value) if not isinstance(value, uuid.UUID): - return '{0:.32x}'.format(uuid.UUID(value)) - # hexstring - return '{0:.32x}'.format(value) + return uuid.UUID(value).hex + # If we get to this point, we assume `value` is a UUID object. + return value.hex def process_result_value(self, value, dialect): if value is None: @@ -232,217 +267,217 @@ def setUp(self): self.manager = APIManager(self.flaskapp, session=self.session) -class TestSupport(ManagerTestBase): - """Base class for test cases which use a database with some basic models. - - """ - - def setUp(self): - """Creates some example models and creates the database tables. - - This class defines a whole bunch of models with various properties for - use in testing, so look here first when writing new tests. - - """ - super(TestSupport, self).setUp() - - # declare the models - class Program(self.Base): - __tablename__ = 'program' - id = Column(Integer, primary_key=True) - name = Column(Unicode, unique=True) - - class ComputerProgram(self.Base): - __tablename__ = 'computer_program' - computer_id = Column(Integer, ForeignKey('computer.id'), - primary_key=True) - program_id = Column(Integer, ForeignKey('program.id'), - primary_key=True) - licensed = Column(Boolean, default=False) - program = relationship('Program') - - class Computer(self.Base): - __tablename__ = 'computer' - id = Column(Integer, primary_key=True) - name = Column(Unicode, unique=True) - vendor = Column(Unicode) - buy_date = Column(DateTime) - owner_id = Column(Integer, ForeignKey('person.id')) - owner = relationship('Person') - programs = relationship('ComputerProgram', - cascade="all, delete-orphan", - backref='computer') - - def speed(self): - return 42 - - @property - def speed_property(self): - return self.speed() - - class Screen(self.Base): - __tablename__ = 'screen' - id = Column(Integer, primary_key=True) - width = Column(Integer, nullable=False) - height = Column(Integer, nullable=False) - - @hybrid_property - def number_of_pixels(self): - return self.width * self.height - - @number_of_pixels.setter - def number_of_pixels(self, value): - self.height = value / self.width - - class Person(self.Base): - __tablename__ = 'person' - id = Column(Integer, primary_key=True) - name = Column(Unicode, unique=True) - age = Column(Integer) - other = Column(Float) - birth_date = Column(Date) - computers = relationship('Computer') - - @hybrid_property - def is_minor(self): - if getattr(self, 'age') is None: - return None - return self.age < 18 - - @hybrid_property - def is_above_21(self): - if getattr(self, 'age') is None: - return None - return self.age > 21 - - @is_above_21.expression - def is_above_21(cls): - return select([cls.age > 21]).as_scalar() - - def name_and_age(self): - return "{0} (aged {1:d})".format(self.name, self.age) - - def first_computer(self): - return sorted(self.computers, key=lambda k: k.name)[0] - - class LazyComputer(self.Base): - __tablename__ = 'lazycomputer' - id = Column(Integer, primary_key=True) - name = Column(Unicode) - ownerid = Column(Integer, ForeignKey('lazyperson.id')) - owner = relationship('LazyPerson', - backref=backref('computers', lazy='dynamic')) - - class LazyPerson(self.Base): - __tablename__ = 'lazyperson' - id = Column(Integer, primary_key=True) - name = Column(Unicode) - - class User(self.Base): - __tablename__ = 'user' - id = Column(Integer, primary_key=True) - email = Column(Unicode, primary_key=True) - wakeup = Column(Time) - - class Planet(self.Base): - __tablename__ = 'planet' - name = Column(Unicode, primary_key=True) - - class Satellite(self.Base): - __tablename__ = 'satellite' - name = Column(Unicode, primary_key=True) - period = Column(Interval, nullable=True) - - class Star(self.Base): - __tablename__ = 'star' - id = Column("star_id", Integer, primary_key=True) - inception_time = Column(DateTime, nullable=True) - - class Vehicle(self.Base): - __tablename__ = 'vehicle' - uuid = Column(GUID, primary_key=True) - - class CarModel(self.Base): - __tablename__ = 'car_model' - id = Column(Integer, primary_key=True) - name = Column(Unicode) - seats = Column(Integer) - - manufacturer_id = Column(Integer, - ForeignKey('car_manufacturer.id')) - manufacturer = relationship('CarManufacturer') - - class CarManufacturer(self.Base): - __tablename__ = 'car_manufacturer' - id = Column(Integer, primary_key=True) - name = Column(Unicode) - models = relationship('CarModel') - - class Project(self.Base): - __tablename__ = 'project' - id = Column(Integer, primary_key=True) - person_id = Column(Integer, ForeignKey('person.id')) - person = relationship('Person', - backref=backref('projects', lazy='dynamic')) - - class Proof(self.Base): - __tablename__ = 'proof' - id = Column(Integer, primary_key=True) - project = relationship('Project', backref=backref('proofs', - lazy='dynamic')) - project_id = Column(Integer, ForeignKey('project.id')) - person = association_proxy('project', 'person') - person_id = association_proxy('project', 'person_id') - - self.Person = Person - self.Program = Program - self.ComputerProgram = ComputerProgram - self.LazyComputer = LazyComputer - self.LazyPerson = LazyPerson - self.User = User - self.Computer = Computer - self.Planet = Planet - self.Satellite = Satellite - self.Star = Star - self.Vehicle = Vehicle - self.CarManufacturer = CarManufacturer - self.CarModel = CarModel - self.Project = Project - self.Proof = Proof - self.Screen = Screen - - # create all the tables required for the models - self.Base.metadata.create_all() - - def tearDown(self): - """Drops all tables from the temporary database.""" - self.Base.metadata.drop_all() - - -class TestSupportPrefilled(TestSupport): - """Base class for tests which use a database and have an - :class:`flask_restless.APIManager` with a :class:`flask.Flask` app object. - - The test client for the :class:`flask.Flask` application is accessible to - test functions at ``self.app`` and the :class:`flask_restless.APIManager` - is accessible at ``self.manager``. - - The database will be prepopulated with five ``Person`` objects. The list of - these objects can be accessed at ``self.people``. - - """ - - def setUp(self): - """Creates the database, the Flask application, and the APIManager.""" - # create the database - super(TestSupportPrefilled, self).setUp() - # create some people in the database for testing - lincoln = self.Person(name=u'Lincoln', age=23, other=22, - birth_date=datetime.date(1900, 1, 2)) - mary = self.Person(name=u'Mary', age=19, other=19) - lucy = self.Person(name=u'Lucy', age=25, other=20) - katy = self.Person(name=u'Katy', age=7, other=10) - john = self.Person(name=u'John', age=28, other=10) - self.people = [lincoln, mary, lucy, katy, john] - self.session.add_all(self.people) - self.session.commit() +# class TestSupport(ManagerTestBase): +# """Base class for test cases which use a database with some basic models. + +# """ + +# def setUp(self): +# """Creates some example models and creates the database tables. + +# This class defines a whole bunch of models with various properties for +# use in testing, so look here first when writing new tests. + +# """ +# super(TestSupport, self).setUp() + +# # declare the models +# class Program(self.Base): +# __tablename__ = 'program' +# id = Column(Integer, primary_key=True) +# name = Column(Unicode, unique=True) + +# class ComputerProgram(self.Base): +# __tablename__ = 'computer_program' +# computer_id = Column(Integer, ForeignKey('computer.id'), +# primary_key=True) +# program_id = Column(Integer, ForeignKey('program.id'), +# primary_key=True) +# licensed = Column(Boolean, default=False) +# program = relationship('Program') + +# class Computer(self.Base): +# __tablename__ = 'computer' +# id = Column(Integer, primary_key=True) +# name = Column(Unicode, unique=True) +# vendor = Column(Unicode) +# buy_date = Column(DateTime) +# owner_id = Column(Integer, ForeignKey('person.id')) +# owner = relationship('Person') +# programs = relationship('ComputerProgram', +# cascade="all, delete-orphan", +# backref='computer') + +# def speed(self): +# return 42 + +# @property +# def speed_property(self): +# return self.speed() + +# class Screen(self.Base): +# __tablename__ = 'screen' +# id = Column(Integer, primary_key=True) +# width = Column(Integer, nullable=False) +# height = Column(Integer, nullable=False) + +# @hybrid_property +# def number_of_pixels(self): +# return self.width * self.height + +# @number_of_pixels.setter +# def number_of_pixels(self, value): +# self.height = value / self.width + +# class Person(self.Base): +# __tablename__ = 'person' +# id = Column(Integer, primary_key=True) +# name = Column(Unicode, unique=True) +# age = Column(Integer) +# other = Column(Float) +# birth_date = Column(Date) +# computers = relationship('Computer') + +# @hybrid_property +# def is_minor(self): +# if getattr(self, 'age') is None: +# return None +# return self.age < 18 + +# @hybrid_property +# def is_above_21(self): +# if getattr(self, 'age') is None: +# return None +# return self.age > 21 + +# @is_above_21.expression +# def is_above_21(cls): +# return select([cls.age > 21]).as_scalar() + +# def name_and_age(self): +# return "{0} (aged {1:d})".format(self.name, self.age) + +# def first_computer(self): +# return sorted(self.computers, key=lambda k: k.name)[0] + +# class LazyComputer(self.Base): +# __tablename__ = 'lazycomputer' +# id = Column(Integer, primary_key=True) +# name = Column(Unicode) +# ownerid = Column(Integer, ForeignKey('lazyperson.id')) +# owner = relationship('LazyPerson', +# backref=backref('computers', lazy='dynamic')) + +# class LazyPerson(self.Base): +# __tablename__ = 'lazyperson' +# id = Column(Integer, primary_key=True) +# name = Column(Unicode) + +# class User(self.Base): +# __tablename__ = 'user' +# id = Column(Integer, primary_key=True) +# email = Column(Unicode, primary_key=True) +# wakeup = Column(Time) + +# class Planet(self.Base): +# __tablename__ = 'planet' +# name = Column(Unicode, primary_key=True) + +# class Satellite(self.Base): +# __tablename__ = 'satellite' +# name = Column(Unicode, primary_key=True) +# period = Column(Interval, nullable=True) + +# class Star(self.Base): +# __tablename__ = 'star' +# id = Column("star_id", Integer, primary_key=True) +# inception_time = Column(DateTime, nullable=True) + +# class Vehicle(self.Base): +# __tablename__ = 'vehicle' +# id = Column(GUID, primary_key=True) + +# class CarModel(self.Base): +# __tablename__ = 'car_model' +# id = Column(Integer, primary_key=True) +# name = Column(Unicode) +# seats = Column(Integer) + +# manufacturer_id = Column(Integer, +# ForeignKey('car_manufacturer.id')) +# manufacturer = relationship('CarManufacturer') + +# class CarManufacturer(self.Base): +# __tablename__ = 'car_manufacturer' +# id = Column(Integer, primary_key=True) +# name = Column(Unicode) +# models = relationship('CarModel') + +# class Project(self.Base): +# __tablename__ = 'project' +# id = Column(Integer, primary_key=True) +# person_id = Column(Integer, ForeignKey('person.id')) +# person = relationship('Person', +# backref=backref('projects', lazy='dynamic')) + +# class Proof(self.Base): +# __tablename__ = 'proof' +# id = Column(Integer, primary_key=True) +# project = relationship('Project', backref=backref('proofs', +# lazy='dynamic')) +# project_id = Column(Integer, ForeignKey('project.id')) +# person = association_proxy('project', 'person') +# person_id = association_proxy('project', 'person_id') + +# self.Person = Person +# self.Program = Program +# self.ComputerProgram = ComputerProgram +# self.LazyComputer = LazyComputer +# self.LazyPerson = LazyPerson +# self.User = User +# self.Computer = Computer +# self.Planet = Planet +# self.Satellite = Satellite +# self.Star = Star +# self.Vehicle = Vehicle +# self.CarManufacturer = CarManufacturer +# self.CarModel = CarModel +# self.Project = Project +# self.Proof = Proof +# self.Screen = Screen + +# # create all the tables required for the models +# self.Base.metadata.create_all() + +# def tearDown(self): +# """Drops all tables from the temporary database.""" +# self.Base.metadata.drop_all() + + +# class TestSupportPrefilled(TestSupport): +# """Base class for tests which use a database and have an +# :class:`flask_restless.APIManager` with a :class:`flask.Flask` app object. + +# The test client for the :class:`flask.Flask` application is accessible to +# test functions at ``self.app`` and the :class:`flask_restless.APIManager` +# is accessible at ``self.manager``. + +# The database will be prepopulated with five ``Person`` objects. The list of +# these objects can be accessed at ``self.people``. + +# """ + +# def setUp(self): +# """Creates the database, the Flask application, and the APIManager.""" +# # create the database +# super(TestSupportPrefilled, self).setUp() +# # create some people in the database for testing +# lincoln = self.Person(name=u'Lincoln', age=23, other=22, +# birth_date=datetime.date(1900, 1, 2)) +# mary = self.Person(name=u'Mary', age=19, other=19) +# lucy = self.Person(name=u'Lucy', age=25, other=20) +# katy = self.Person(name=u'Katy', age=7, other=10) +# john = self.Person(name=u'John', age=28, other=10) +# self.people = [lincoln, mary, lucy, katy, john] +# self.session.add_all(self.people) +# self.session.commit() diff --git a/tests/test_creating.py b/tests/test_creating.py new file mode 100644 index 00000000..50fcacc9 --- /dev/null +++ b/tests/test_creating.py @@ -0,0 +1,463 @@ +# -*- encoding: utf-8 -*- +""" + tests.test_creating + ~~~~~~~~~~~~~~~~~~~ + + Provides tests for creating resources from endpoints generated by + Flask-Restless. + + This module includes tests for additional functionality that is not already + tested by :mod:`test_jsonapi`, the module that guarantees Flask-Restless + meets the minimum requirements of the JSON API specification. + + :copyright: 2015 Jeffrey Finkelstein and + contributors. + :license: GNU AGPLv3+ or BSD + +""" +from __future__ import division +from json import JSONEncoder +from datetime import time +from datetime import timedelta +from datetime import datetime + +import dateutil +from sqlalchemy import Column +from sqlalchemy import Date +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Interval +from sqlalchemy import Time +from sqlalchemy import Unicode +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import backref +from sqlalchemy.orm import relationship + +from flask.ext.restless import CONTENT_TYPE + +from .helpers import dumps +from .helpers import loads +from .helpers import ManagerTestBase +from .helpers import MSIE8_UA +from .helpers import MSIE9_UA + + +class TestCreating(ManagerTestBase): + """Tests for creating resources.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestCreating, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + date_created = Column(Date) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person') + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + age = Column(Integer) + name = Column(Unicode, unique=True) + birth_datetime = Column(DateTime, nullable=True) + bedtime = Column(Time) + hangtime = Column(Interval) + articles = relationship('Article') + + @hybrid_property + def is_minor(self): + if hasattr(self, 'age'): + if self.age is None: + return None + return self.age < 18 + return None + + class Tag(self.Base): + __tablename__ = 'tag' + name = Column(Unicode, primary_key=True) + + self.Article = Article + self.Person = Person + self.Tag = Tag + self.Base.metadata.create_all() + self.manager.create_api(Person, methods=['POST']) + self.manager.create_api(Article, methods=['POST']) + self.manager.create_api(Tag, methods=['POST']) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_deserializing_time(self): + """Test for deserializing a JSON representation of a time field.""" + # datetime.time objects are not serializable by default so we need to + # create a custom JSON encoder class. + class TimeEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, time): + return o.isoformat() + return super(self, JSONEncoder).default(o) + bedtime = datetime.now().time() + data = dict(data=dict(type='person', bedtime=bedtime)) + response = self.app.post('/api/person', data=dumps(data, + cls=TimeEncoder)) + assert response.status_code == 201 + document = loads(response.data) + person = document['data'] + assert person['bedtime'] == bedtime.isoformat() + + def test_deserializing_date(self): + """Test for deserializing a JSON representation of a date field.""" + date_created = datetime.now().date() + data = dict(data=dict(type='article', date_created=date_created)) + response = self.app.post('/api/article', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + article = document['data'] + assert article['date_created'] == date_created.isoformat() + + def test_deserializing_datetime(self): + """Test for deserializing a JSON representation of a date field.""" + birth_datetime = datetime.now() + data = dict(data=dict(type='person', birth_datetime=birth_datetime)) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + person = document['data'] + # When we did `dumps(data)` above, we lost the millisecond information, + # so we expect the created person to not have that extra information. + isodatetime = birth_datetime.isoformat() + expected_datetime = isodatetime[:isodatetime.rfind('.')] + assert person['birth_datetime'] == expected_datetime + + def test_correct_content_type(self): + """Tests that the server responds with :http:status:`201` if the + request has the correct JSON API content type. + + """ + data = dict(data=dict(type='person')) + response = self.app.post('/api/person', data=dumps(data), + content_type=CONTENT_TYPE) + assert response.status_code == 201 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_no_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has no content type. + + """ + data = dict(data=dict(type='person')) + response = self.app.post('/api/person', data=dumps(data), + content_type=None) + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_wrong_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has the wrong content type. + + """ + data = dict(data=dict(type='person')) + bad_content_types = ('application/json', 'application/javascript') + for content_type in bad_content_types: + response = self.app.post('/api/person', data=dumps(data), + content_type=content_type) + # TODO Why are there two copies of the Content-Type header here? + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_msie8(self): + """Tests for compatibility with Microsoft Internet Explorer 8. + + According to issue #267, making requests using JavaScript from MSIE8 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + headers = {'User-Agent': MSIE8_UA} + content_type = 'text/html' + data = dict(data=dict(type='person')) + response = self.app.post('/api/person', data=dumps(data), + headers=headers, content_type=content_type) + assert response.status_code == 201 + + def test_msie9(self): + """Tests for compatibility with Microsoft Internet Explorer 9. + + According to issue #267, making requests using JavaScript from MSIE9 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + headers = {'User-Agent': MSIE9_UA} + content_type = 'text/html' + data = dict(data=dict(type='person')) + response = self.app.post('/api/person', data=dumps(data), + headers=headers, content_type=content_type) + print(response.data) + assert response.status_code == 201 + + def test_no_data(self): + """Tests that a request with no data yields an error response.""" + response = self.app.post('/api/person') + assert response.status_code == 400 + # TODO check the error message here + + def test_invalid_json(self): + """Tests that a request with an invalid JSON causes an error response. + + """ + response = self.app.post('/api/person', data='Invalid JSON string') + assert response.status_code == 400 + # TODO check the error message here + + def test_conflicting_attributes(self): + """Tests that an attempt to create a resource with a non-unique + attribute value where uniqueness is required causes a + :http:status:`409` response. + + """ + person = self.Person(name='foo') + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', name='foo')) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 409 # Conflict + # TODO check error message here + + def test_rollback_on_integrity_error(self): + """Tests that an integrity error in the database causes a session + rollback, and that the server can still process requests correctly + after this rollback. + + """ + person = self.Person(name='foo') + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', name='foo')) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 409 # Conflict + assert self.session.is_active, 'Session is in `partial rollback` state' + person = dict(data=dict(type='person', name='bar')) + response = self.app.post('/api/person', data=dumps(person)) + assert response.status_code == 201 + + def test_nonexistent_attribute(self): + """Tests that the server rejects an attempt to create a resource with + an attribute that does not exist in the resource. + + """ + data = dict(data=dict(type='person', bogus=0)) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 400 + # TODO check error message here + + def test_hybrid_property(self): + """Tests that an attempt to set a read-only hybrid property causes an + error. + + See issue #171. + + """ + data = dict(data=dict(type='person', is_minor=True)) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 400 + # TODO check error message here + + def test_nullable_datetime(self): + """Tests for creating a model with a nullable datetime field. + + For more information, see issue #91. + + """ + data = dict(data=dict(type='person', birth_datetime=None)) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + person = document['data'] + assert person['birth_datetime'] is None + + def test_empty_date(self): + """Tests that attempting to assign an empty date string to a date field + actually assigns a value of ``None``. + + For more information, see issue #91. + + """ + data = dict(data=dict(type='person', birth_datetime='')) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + person = document['data'] + assert person['birth_datetime'] is None + + def test_current_timestamp(self): + """Tests that the string ``'CURRENT_TIMESTAMP'`` gets converted into a + datetime object when making a request to set a date or time field. + + """ + data = dict(data=dict(type='person', + birth_datetime='CURRENT_TIMESTAMP')) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + person = document['data'] + assert person['birth_datetime'] is not None + birth_datetime = dateutil.parser.parse(person['birth_datetime']) + diff = datetime.utcnow() - birth_datetime + # Check that the total number of seconds from the server creating the + # Person object to (about) now is not more than about a minute. + assert diff.days == 0 + assert (diff.seconds + diff.microseconds / 1000000) < 3600 + + def test_timedelta(self): + """Tests for creating an object with a timedelta attribute.""" + oldJSONEncoder = self.flaskapp.json_encoder + + class IntervalJSONEncoder(oldJSONEncoder): + def default(self, obj): + if isinstance(obj, timedelta): + return int(obj.days * 86400 + obj.seconds) + return oldJSONEncoder.default(self, obj) + + self.flaskapp.json_encoder = IntervalJSONEncoder + data = dict(data=dict(type='person', hangtime=300)) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + person = document['data'] + assert person['hangtime'] == 300 + + def test_to_many(self): + """Tests the creation of a model with a to-many relation.""" + article1 = self.Article(id=1) + article2 = self.Article(id=2) + self.session.add_all([article1, article2]) + self.session.commit() + data = dict(data=dict(type='person', + links=dict(articles=dict(type='article', + ids=[1, 2])))) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + person = document['data'] + articles = person['links']['articles'] + assert ['1', '2'] == sorted(articles['ids']) + + def test_to_one(self): + """Tests the creation of a model with a to-one relation.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='article', + links=dict(author=dict(type='person', id=1)))) + response = self.app.post('/api/article', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + article = document['data'] + print('article:', article) + person = article['links']['author'] + assert person['id'] == '1' + + def test_unicode_primary_key(self): + """Test for creating a resource with a unicode primary key.""" + data = dict(data=dict(type='tag', name=u'Юникод')) + response = self.app.post('/api/tag', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + tag = document['data'] + assert tag['name'] == u'Юникод' + + # TODO This behavior is no longer supported + # + # def test_nested_relations(self): + # # Test with nested objects + # data = {'name': 'Rodriguez', 'age': 70, + # 'computers': [{'name': 'iMac', 'vendor': 'Apple', + # 'programs': [{'program': {'name': 'iPhoto'}}]}]} + # response = self.app.post('/api/person', data=dumps(data)) + # assert 201 == response.status_code + # response = self.app.get('/api/computer/2/programs') + # programs = loads(response.data)['objects'] + # assert programs[0]['program']['name'] == 'iPhoto' + + +class TestAssociationProxy(ManagerTestBase): + """Tests for creating an object with a relationship using an association + proxy. + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask.ext.restless.manager.APIManager` for that application, + and creates the ReSTful API endpoints for the models used in the test + methods. + + """ + super(TestAssociationProxy, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + tags = association_proxy('articletags', 'tag', + creator=lambda tag: ArticleTag(tag=tag)) + + class ArticleTag(self.Base): + __tablename__ = 'articletag' + article_id = Column(Integer, ForeignKey('article.id'), + primary_key=True) + article = relationship(Article, backref=backref('articletags')) + tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True) + tag = relationship('Tag') + # extra_info = Column(Unicode) + + class Tag(self.Base): + __tablename__ = 'tag' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + + self.Tag = Tag + self.Base.metadata.create_all() + self.manager.create_api(Article, methods=['POST']) + # HACK Need to create APIs for these other models because otherwise + # we're not able to create the link URLs to them. + # + # TODO Fix this by simply not creating links to related models for + # which no API has been made. + self.manager.create_api(Tag) + self.manager.create_api(ArticleTag) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_create(self): + """Test for creating a new instance of the database model that has a + many-to-many relation that uses an association object to allow extra + information to be stored on the association table. + + """ + tag = self.Tag(id=1) + self.session.add(tag) + self.session.commit() + data = dict(data=dict(type='article', + links=dict(tags=dict(type='tags', ids=[1])))) + response = self.app.post('/api/article', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + article = document['data'] + links = article['links'] + tags = links['tags'] + assert ['1'] == sorted(tags['ids']) diff --git a/tests/test_deleting.py b/tests/test_deleting.py new file mode 100644 index 00000000..e16250e3 --- /dev/null +++ b/tests/test_deleting.py @@ -0,0 +1,233 @@ +""" + tests.test_deleting + ~~~~~~~~~~~~~~~~~~~ + + Provides tests for deleting resources from endpoints generated by + Flask-Restless. + + This module includes tests for additional functionality that is not already + tested by :mod:`test_jsonapi`, the module that guarantees Flask-Restless + meets the minimum requirements of the JSON API specification. + + :copyright: 2015 Jeffrey Finkelstein and + contributors. + :license: GNU AGPLv3+ or BSD + +""" +from flask import json +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import Unicode + +from flask.ext.restless import CONTENT_TYPE + +from .helpers import dumps +from .helpers import loads +from .helpers import ManagerTestBase +from .helpers import MSIE8_UA +from .helpers import MSIE9_UA + + +class TestDeleting(ManagerTestBase): + """Tests for deleting resources.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestDeleting, self).setUp() + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Person, methods=['DELETE']) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_correct_content_type(self): + """Tests that the server responds with :http:status:`201` if the + request has the correct JSON API content type. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.delete('/api/person/1', content_type=CONTENT_TYPE) + assert response.status_code == 204 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_no_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has no content type. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.delete('/api/person/1', content_type=None) + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_wrong_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has the wrong content type. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + bad_content_types = ('application/json', 'application/javascript') + for content_type in bad_content_types: + response = self.app.delete('/api/person/1', + content_type=content_type) + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_msie8(self): + """Tests for compatibility with Microsoft Internet Explorer 8. + + According to issue #267, making requests using JavaScript from MSIE8 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + headers = {'User-Agent': MSIE8_UA} + content_type = 'text/html' + response = self.app.delete('/api/person/1', headers=headers, + content_type=content_type) + assert response.status_code == 204 + + def test_msie9(self): + """Tests for compatibility with Microsoft Internet Explorer 9. + + According to issue #267, making requests using JavaScript from MSIE9 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + headers = {'User-Agent': MSIE9_UA} + content_type = 'text/html' + response = self.app.delete('/api/person/1', headers=headers, + content_type=content_type) + assert response.status_code == 204 + + def test_disallow_delete_many(self): + """Tests that deleting an entire collection is disallowed by default. + + Deleting an entire collection is not discussed in the JSON API + specification. + + """ + response = self.app.delete('/api/person') + assert response.status_code == 405 + + def test_delete_collection(self): + """Tests for deleting all instances of a collection. + + Deleting an entire collection is not discussed in the JSON API + specification. + + """ + self.session.add_all(self.Person() for n in range(3)) + self.session.commit() + self.manager.create_api(self.Person, methods=['DELETE'], + allow_delete_many=True, url_prefix='/api2') + response = self.app.delete('/api2/person') + assert response.status_code == 204 + assert self.session.query(self.Person).count() == 0 + + def test_delete_many_filtered(self): + """Tests for deleting instances of a collection selected by filters. + + Deleting from a collection is not discussed in the JSON API + specification. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + person3 = self.Person(id=3) + self.session.add_all([person1, person2, person3]) + self.session.commit() + self.manager.create_api(self.Person, methods=['DELETE'], + allow_delete_many=True, url_prefix='/api2') + filters = [dict(name='id', op='lt', val=3)] + url = '/api2/person?filter[objects]={0}'.format(dumps(filters)) + response = self.app.delete(url) + print(response.data) + assert response.status_code == 204 + assert [person3] == self.session.query(self.Person).all() + + def test_delete_integrity_error(self): + """Tests that an :exc:`IntegrityError` raised in a + :http:method:`delete` request is caught and returned to the client + safely. + + """ + assert False, 'Not implemented' + + def test_delete_absent_instance(self): + """Test that deleting an instance of the model which does not exist + fails. + + This should give us a 404 when the object is not found. + + """ + response = self.app.delete('/api/person/1') + assert response.status_code == 404 + + # TODO tested elsewhere + # + # def test_delete_from_relation(self): + # """Tests that a :http:method:`delete` request to a related instance + # removes that related instance from the specified model. + + # See issue #193. + + # """ + # person = self.Person() + # computer = self.Computer() + # person.computers.append(computer) + # self.session.add_all((person, computer)) + # self.session.commit() + # # Delete the related computer. + # response = self.app.delete('/api/person/1/computers/1') + # assert response.status_code == 204 + # # Check that it is actually gone from the relation. + # response = self.app.get('/api/person/1') + # assert response.status_code == 200 + # assert len(loads(response.data)['computers']) == 0 + # # Check that the related instance hasn't been deleted from the database + # # altogether. + # response = self.app.get('/api/computer/1') + # assert response.status_code == 200 + + # # # Add the computer back in to the relation and use the Delete-Orphan + # # # header to instruct the server to delete the orphaned computer + # # # instance. + # # person.computers.append(computer) + # # self.session.commit() + # # response = self.app.delete('/api/person/1/computers/1', + # # headers={'Delete-Orphan': 1}) + # # assert response.status_code == 204 + # # response = self.app.get('/api/person/1/computers') + # # assert response.status_code == 200 + # # assert len(loads(response.data)['computers']) == 0 + # # response = self.app.get('/api/computers') + # # assert response.status_code == 200 + # # assert len(loads(response.data)['objects']) == 0 diff --git a/tests/test_fetching.py b/tests/test_fetching.py new file mode 100644 index 00000000..37f88e3e --- /dev/null +++ b/tests/test_fetching.py @@ -0,0 +1,408 @@ +""" + tests.test_fetching + ~~~~~~~~~~~~~~~~~~~ + + Provides tests for fetching resources from endpoints generated by + Flask-Restless. + + This module includes tests for additional functionality that is not already + tested by :mod:`test_jsonapi`, the module that guarantees Flask-Restless + meets the minimum requirements of the JSON API specification. + + :copyright: 2015 Jeffrey Finkelstein and + contributors. + :license: GNU AGPLv3+ or BSD + +""" +from datetime import date +from datetime import datetime +from datetime import time + +try: + from flask.ext.sqlalchemy import SQLAlchemy +except: + has_flask_sqlalchemy = False +else: + has_flask_sqlalchemy = True +from sqlalchemy import Column +from sqlalchemy import Date +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Time +from sqlalchemy import Unicode +from sqlalchemy.orm import backref +from sqlalchemy.orm import relationship + +from flask.ext.restless import APIManager +from flask.ext.restless import CONTENT_TYPE + +from .helpers import FlaskTestBase +from .helpers import loads +from .helpers import MSIE8_UA +from .helpers import MSIE9_UA +from .helpers import ManagerTestBase +from .helpers import skip_unless +from .helpers import unregister_fsa_session_signals + + +class TestFetching(ManagerTestBase): + """Tests for fetching resources.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestFetching, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + title = Column(Unicode, primary_key=True) + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + bedtime = Column(Time) + birth_datetime = Column(DateTime) + birthday = Column(Date) + + class Tag(self.Base): + __tablename__ = 'tag' + name = Column(Unicode, primary_key=True) + + self.Article = Article + self.Person = Person + self.Tag = Tag + self.Base.metadata.create_all() + self.manager.create_api(Article) + self.manager.create_api(Person) + self.manager.create_api(Tag) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_serialize_time(self): + """Test for getting the JSON representation of a time field.""" + now = datetime.now().time() + person = self.Person(id=1, bedtime=now) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1') + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['bedtime'] == now.isoformat() + + def test_serialize_datetime(self): + """Test for getting the JSON representation of a datetime field.""" + now = datetime.now() + person = self.Person(id=1, birth_datetime=now) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1') + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['birth_datetime'] == now.isoformat() + + def test_serialize_date(self): + """Test for getting the JSON representation of a date field.""" + now = datetime.now().date() + person = self.Person(id=1, birthday=now) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1') + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['birthday'] == now.isoformat() + + # def test_num_results(self): + # """Tests that a request for (a subset of) all instances of a model + # includes the total number of results as part of the JSON response. + + # """ + # self.manager.create_api(self.Person) + # for i in range(15): + # d = dict(name='person{0}'.format(i)) + # response = self.app.post('/api/person', data=dumps(d)) + # assert response.status_code == 201 + # response = self.app.get('/api/person') + # assert response.status_code == 200 + # data = loads(response.data) + # assert data['meta']['num_results'] == 15 + + def test_alternate_primary_key(self): + """Tests that models with primary keys that are not named ``id`` are + are still accessible via their primary keys. + + """ + tag = self.Tag(name=u'foo') + self.session.add(tag) + self.session.commit() + response = self.app.get('/api/tag/foo') + document = loads(response.data) + tag = document['data'] + assert tag['id'] == 'foo' + + def test_primary_key_int_string(self): + """Tests for getting a resource that has a string primary key, + including the possibility of a string representation of a number. + + """ + tag = self.Tag(name=u'1') + self.session.add(tag) + self.session.commit() + response = self.app.get('/api/tag/1') + document = loads(response.data) + tag = document['data'] + assert tag['name'] == '1' + + def test_jsonp(self): + """Test for a JSON-P callback on a single resource request.""" + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + response = self.app.get('/api/person/1?callback=foo') + assert response.data.startswith(b'foo(') + assert response.data.endswith(b')') + document = loads(response.data[4:-1]) + person = document['data'] + assert person['id'] == '1' + + def test_jsonp_collection(self): + """Test for a JSON-P callback on a collection of resources.""" + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + response = self.app.get('/api/person?callback=foo') + assert response.data.startswith(b'foo(') + assert response.data.endswith(b')') + document = loads(response.data[4:-1]) + people = document['data'] + assert ['1', '2'] == sorted(person['id'] for person in people) + + def test_callable_query_attribute(self): + """Tests that a callable model.query attribute is being used when + available. + + """ + + def query(cls): + return self.session.query(cls).filter(self.Person.id > 1) + + self.Person.query = classmethod(query) + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + response = self.app.get('/api/person') + document = loads(response.data) + people = document['data'] + assert ['2'] == sorted(person['id'] for person in people) + + def test_specified_primary_key(self): + """Tests that models with more than one primary key are accessible via + a primary key specified by the server. + + """ + article = self.Article(id=1, title='foo') + self.session.add(article) + self.session.commit() + self.manager.create_api(self.Article, url_prefix='/api2', + primary_key='title') + response = self.app.get('/api2/article/1') + assert response.status_code == 404 + response = self.app.get('/api2/article/foo') + assert response.status_code == 200 + document = loads(response.data) + resource = document['data'] + # Resource objects must have string IDs. + assert resource['id'] == str(article.id) + assert resource['title'] == article.title + + def test_correct_content_type(self): + """Tests that the server responds with :http:status:`200` if the + request has the correct JSON API content type. + + """ + response = self.app.get('/api/person', content_type=CONTENT_TYPE) + assert response.status_code == 200 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_no_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has no content type. + + """ + response = self.app.get('/api/person', content_type=None) + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_wrong_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has the wrong content type. + + """ + bad_content_types = ('application/json', 'application/javascript') + for content_type in bad_content_types: + response = self.app.get('/api/person', content_type=content_type) + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_msie8(self): + """Tests for compatibility with Microsoft Internet Explorer 8. + + According to issue #267, making requests using JavaScript from MSIE8 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + headers = {'User-Agent': MSIE8_UA} + content_type = 'text/html' + response = self.app.get('/api/person', headers=headers, + content_type=content_type) + assert response.status_code == 200 + + def test_msie9(self): + """Tests for compatibility with Microsoft Internet Explorer 9. + + According to issue #267, making requests using JavaScript from MSIE9 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + headers = {'User-Agent': MSIE9_UA} + content_type = 'text/html' + response = self.app.get('/api/person', headers=headers, + content_type=content_type) + assert response.status_code == 200 + + +class TestDynamicRelationships(ManagerTestBase): + """Tests for fetching resources from dynamic relationships.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestDynamicRelationships, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person', backref=backref('articles', + lazy='dynamic')) + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + + self.Article = Article + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Article) + self.manager.create_api(Person) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_to_many(self): + """Tests for fetching a resource with a dynamic link to a to-many + relation. + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + article1.author = person + article2.author = person + self.session.add_all([person, article1, article2]) + self.session.commit() + response = self.app.get('/api/person/1') + document = loads(response.data) + person = document['data'] + links = person['links'] + articles = links['articles'] + print(articles) + assert ['1', '2'] == sorted(articleid for articleid in articles['ids']) + + def test_to_many_resource_url(self): + """Tests for fetching a resource with a dynamic link to a to-many + relation from the related resource URL. + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + article1.author = person + article2.author = person + self.session.add_all([person, article1, article2]) + self.session.commit() + response = self.app.get('/api/person/1/articles') + document = loads(response.data) + articles = document['data'] + assert ['1', '2'] == sorted(article['id'] for article in articles) + + +@skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') +class TestFlaskSqlalchemy(FlaskTestBase): + """Tests for fetching resources defined as Flask-SQLAlchemy models instead + of pure SQLAlchemy models. + + """ + + def setUp(self): + """Creates the Flask-SQLAlchemy database and models.""" + super(TestFlaskSqlalchemy, self).setUp() + self.db = SQLAlchemy(self.flaskapp) + self.session = self.db.session + + class Person(self.db.Model): + id = self.db.Column(self.db.Integer, primary_key=True) + + self.Person = Person + self.db.create_all() + self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db) + self.manager.create_api(self.Person) + + def tearDown(self): + """Drops all tables.""" + self.db.drop_all() + unregister_fsa_session_signals() + + def test_fetch_resource(self): + """Test for fetching a resource.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1') + document = loads(response.data) + person = document['data'] + assert person['id'] == '1' + assert person['type'] == 'person' + + def test_fetch_collection(self): + """Test for fetching a collection of resource.""" + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + response = self.app.get('/api/person') + document = loads(response.data) + people = document['data'] + assert ['1', '2'] == sorted(person['id'] for person in people) diff --git a/tests/test_filtering.py b/tests/test_filtering.py new file mode 100644 index 00000000..bee66dba --- /dev/null +++ b/tests/test_filtering.py @@ -0,0 +1,546 @@ +""" + tests.test_filtering + ~~~~~~~~~~~~~~~~~~~~ + + Provides tests for filtering resources in client requests. + + :copyright: 2015 Jeffrey Finkelstein and + contributors. + :license: GNU AGPLv3+ or BSD + +""" +try: + from urllib.parse import quote as url_quote +except ImportError: + from urllib import quote as url_quote +from datetime import date + +from sqlalchemy import Column +from sqlalchemy import Date +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Unicode +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.orm import backref +from sqlalchemy.orm import relationship + +from .helpers import dumps +from .helpers import loads +from .helpers import skip +from .helpers import ManagerTestBase + + +class TestFiltering(ManagerTestBase): + """Tests for filtering resources.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask.ext.restless.manager.APIManager` for that application, + and creates the ReSTful API endpoints for the models used in the test + methods. + + """ + super(TestFiltering, self).setUp() + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + age = Column(Integer) + birthday = Column(Date) + + class Comment(self.Base): + __tablename__ = 'comment' + id = Column(Integer, primary_key=True) + content = Column(Unicode) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person', backref=backref('comments')) + + self.Person = Person + self.Comment = Comment + self.Base.metadata.create_all() + self.manager.create_api(Person) + # HACK Need to create APIs for these other models because otherwise + # we're not able to create the link URLs to them. + # + # TODO Fix this by simply not creating links to related models for + # which no API has been made. + self.manager.create_api(Comment) + + def search(self, url, filters=None, single=None): + """Convenience function for performing a filtered :http:method:`get` + request. + + `url` is the ``path`` part of the URL to which the request will be + sent. + + If `filters` is specified, it must be a Python list containing filter + objects. It specifies how to set the ``filter[objects]`` query + parameter. + + If `single` is specified, it must be a Boolean. It specifies how to set + the ``filter[single]`` query parameter. + + """ + if filters is None: + filters = [] + target_url = '{0}?filter[objects]={1}'.format(url, dumps(filters)) + if single is not None: + target_url += '&filter[single]={0}'.format(1 if single else 0) + return self.app.get(target_url) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_bad_filter(self): + """Tests that providing a bad filter parameter causes an error + response. + + """ + response = self.app.get('/api/person?filter[objects]=bogus') + assert response.status_code == 400 + # TODO check error messages here + + def test_like(self): + """Tests for filtering using the ``like`` operator.""" + person1 = self.Person(name='Jesus') + person2 = self.Person(name='Mary') + person3 = self.Person(name='Joseph') + self.session.add_all([person1, person2, person3]) + self.session.commit() + filters = [dict(name='name', op='like', val=url_quote('%s%'))] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 2 + assert ['Jesus', 'Joseph'] == sorted(p['name'] for p in people) + + def test_single(self): + """Tests for requiring a single resource response to a filtered + request. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + filters = [dict(name='id', op='equals', val='1')] + response = self.search('/api/person', filters, single=True) + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['id'] == '1' + + def test_single_too_many(self): + """Tests that requiring a single resource response returns an error if + the filtered request would have returned more than one resource. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + response = self.search('/api/person', single=True) + # TODO should this be a 404? Maybe 409 is better? + assert response.status_code == 404 + # TODO check the error message here. + + def test_single_wrong_format(self): + """Tests that providing an incorrectly formatted argument to + ``filter[single]`` yields an error response. + + """ + response = self.app.get('/api/person?filter[single]=bogus') + assert response.status_code == 400 + # TODO check the error message here. + + def test_in_list(self): + """Tests for a filter object checking for a field with value in a + specified list of acceptable values. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + person3 = self.Person(id=3) + self.session.add_all([person1, person2, person3]) + self.session.commit() + filters = [dict(name='id', op='in', val=[2, 3])] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 2 + assert ['2', '3'] == sorted(person['id'] for person in people) + + def test_any_in_to_many(self): + """Test for filtering using the ``any`` operator with a sub-filter + object on a to-many relationship. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + person3 = self.Person(id=3) + comment1 = self.Comment(content="that's cool!", author=person1) + comment2 = self.Comment(content='i like turtles', author=person2) + comment3 = self.Comment(content='not cool dude', author=person3) + self.session.add_all([person1, person2, person3]) + self.session.add_all([comment1, comment2, comment3]) + self.session.commit() + # Search for any people who have comments that contain the word "cool". + filters = [dict(name='comments', op='any', + val=dict(name='content', op='like', + val=url_quote('%cool%')))] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 2 + assert ['1', '3'] == sorted(person['id'] for person in people) + + def test_has_in_to_one(self): + """Test for filtering using the ``has`` operator with a sub-filter + object on a to-one relationship. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + person3 = self.Person(id=3) + comment1 = self.Comment(content="that's cool!", author=person1) + comment2 = self.Comment(content="i like turtles", author=person2) + comment3 = self.Comment(content="not cool dude", author=person3) + self.session.add_all([person1, person2, person3]) + self.session.add_all([comment1, comment2, comment3]) + self.session.commit() + # Search for any comments whose author has ID equals to 1. + filters = [dict(name='author', op='has', + val=dict(name='id', op='gt', val=1))] + response = self.search('/api/comment', filters) + document = loads(response.data) + comments = document['data'] + assert len(comments) == 2 + assert ['2', '3'] == sorted(comment['id'] for comment in comments) + + def test_comparing_fields(self): + """Test for comparing the value of two fields in a filter object.""" + person1 = self.Person(id=1, age=1) + person2 = self.Person(id=2, age=3) + person3 = self.Person(id=3, age=3) + self.session.add_all([person1, person2, person3]) + self.session.commit() + filters = [dict(name='age', op='eq', field='id')] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 2 + assert ['1', '3'] == sorted(person['id'] for person in people) + + def test_date_yyyy_mm_dd(self): + """Test for date parsing in filter objects with dates of the form + ``1969-07-20``. + + """ + person1 = self.Person(id=1, birthday=date(1969, 7, 20)) + person2 = self.Person(id=2, birthday=date(1900, 1, 2)) + self.session.add_all([person1, person2]) + self.session.commit() + filters = [dict(name='birthday', op='eq', val='1900-01-02')] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 1 + assert people[0]['id'] == '2' + + def test_date_english(self): + """Tests for date parsing in filter object with dates of the form ``2nd + Jan 1900``. + + """ + person1 = self.Person(id=1, birthday=date(1969, 7, 20)) + person2 = self.Person(id=2, birthday=date(1900, 1, 2)) + self.session.add_all([person1, person2]) + self.session.commit() + filters = [dict(name='birthday', op='eq', val='2nd Jan 1900')] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 1 + assert people[0]['id'] == '2' + + def test_times(self): + """Test for time parsing in filter objects.""" + assert False, 'Not implemented' + + def test_datetimes(self): + """Test for datetime parsing in filter objects.""" + assert False, 'Not implemented' + + def test_datetime_to_date(self): + """Tests that a filter object with a datetime value and a field with a + ``Date`` type automatically converts the datetime to a date. + + """ + person1 = self.Person(id=1, birthday=date(1969, 7, 20)) + person2 = self.Person(id=2, birthday=date(1900, 1, 2)) + self.session.add_all([person1, person2]) + self.session.commit() + datestring = '2nd Jan 1900 14:35' + filters = [dict(name='birthday', op='eq', val=datestring)] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 1 + assert people[0]['id'] == '2' + + def test_datetime_to_time(self): + """Test that a datetime gets truncated to a time if the model has a + time field. + + """ + assert False, 'Not implemented' + + def test_bad_date(self): + """Tests that an invalid date causes an error.""" + filters = [dict(name='birthday', op='eq', val='bogus')] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check error message here + + def test_bad_time(self): + """Tests that an invalid time causes an error.""" + filters = [dict(name='bedtime', op='eq', val='bogus')] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check error message here + + def test_bad_datetime(self): + """Tests that an invalid datetime causes an error.""" + filters = [dict(name='created_at', op='eq', val='bogus')] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check error message here + + def test_search_boolean_formula(self): + """Tests for Boolean formulas of filters in a search query.""" + person1 = self.Person(id=1, name='John', age=10) + person2 = self.Person(id=2, name='Paul', age=20) + person3 = self.Person(id=3, name='Luke', age=30) + person4 = self.Person(id=4, name='Matthew', age=40) + self.session.add_all([person1, person2, person3, person4]) + self.session.commit() + # This searches for people whose name is John, or people older than age + # 10 who have a "u" in their names. This should return three people: + # John, Paul, and Luke. + filters = [{'or': [{'and': [dict(name='name', op='like', + val=url_quote('%u%')), + dict(name='age', op='ge', val=10)]}, + dict(name='name', op='eq', val='John')] + }] + response = self.search('/api/person', filters) + document = loads(response.data) + people = document['data'] + assert len(people) == 3 + assert ['1', '2', '3'] == sorted(person['id'] for person in people) + + @skip("I'm not certain in what situations an invalid value should cause" + " a SQLAlchemy error") + def test_invalid_value(self): + """Tests for an error response on an invalid value in a filter object. + + """ + filters = [dict(name='age', op='>', val='should not be a string')] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check the error message here + + def test_invalid_field(self): + """Tests for an error response on an invalid field name in a filter + object. + + """ + filters = [dict(name='foo', op='>', val=2)] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check the error message here + + def test_invalid_operator(self): + """Tests for an error response on an invalid operator in a filter + object. + + """ + filters = [dict(name='age', op='bogus', val=2)] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check the error message here + + def test_missing_argument(self): + """Tests that filter requests with a missing ``'val'`` causes an error + response. + + """ + filters = [dict(name='name', op='==')] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check error message here + + def test_missing_fieldname(self): + """Tests that filter requests with a missing ``'name'`` causes an error + response. + + """ + filters = [dict(op='==', val='foo')] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check error message here + + def test_missing_operator(self): + """Tests that filter requests with a missing ``'op'`` causes an error + response. + + """ + filters = [dict(name='age', val=3)] + response = self.search('/api/person', filters) + assert response.status_code == 400 + # TODO check error message here + + +class TestAssociationProxy(ManagerTestBase): + """Test for filtering on association proxies.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask.ext.restless.manager.APIManager` for that application, + and creates the ReSTful API endpoints for the models used in the test + methods. + + """ + super(TestAssociationProxy, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + tags = association_proxy('articletags', 'tag', + creator=lambda tag: ArticleTag(tag=tag)) + + class ArticleTag(self.Base): + __tablename__ = 'articletag' + article_id = Column(Integer, ForeignKey('article.id'), + primary_key=True) + article = relationship(Article, backref=backref('articletags')) + tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True) + tag = relationship('Tag') + # extra_info = Column(Unicode) + + class Tag(self.Base): + __tablename__ = 'tag' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + + self.Article = Article + self.Tag = Tag + self.Base.metadata.create_all() + self.manager.create_api(Article) + # HACK Need to create APIs for these other models because otherwise + # we're not able to create the link URLs to them. + # + # TODO Fix this by simply not creating links to related models for + # which no API has been made. + self.manager.create_api(ArticleTag) + self.manager.create_api(Tag) + + # TODO refactor this method + def search(self, url, filters=None, single=None): + """Convenience function for performing a filtered :http:method:`get` + request. + + `url` is the ``path`` part of the URL to which the request will be + sent. + + If `filters` is specified, it must be a Python list containing filter + objects. It specifies how to set the ``filter[objects]`` query + parameter. + + If `single` is specified, it must be a Boolean. It specifies how to set + the ``filter[single]`` query parameter. + + """ + if filters is None: + filters = [] + target_url = '{0}?filter[objects]={1}'.format(url, dumps(filters)) + if single is not None: + target_url += '&filter[single]={0}'.format(1 if single else 0) + return self.app.get(target_url) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_any(self): + """Tests for filtering on a many-to-many relationship via an + association proxy backed by an association object. + + """ + article1 = self.Article(id=1) + article2 = self.Article(id=2) + article3 = self.Article(id=3) + tag1 = self.Tag(name='foo') + tag2 = self.Tag(name='bar') + tag3 = self.Tag(name='baz') + article1.tags = [tag1, tag2] + article2.tags = [tag2, tag3] + article3.tags = [tag3, tag1] + self.session.add_all([article1, article2, article3]) + self.session.add_all([tag1, tag2, tag3]) + self.session.commit() + filters = [dict(name='tags', op='any', + val=dict(name='name', op='eq', val='bar'))] + response = self.search('/api/article', filters) + document = loads(response.data) + articles = document['data'] + assert ['1', '2'] == sorted(article['id'] for article in articles) + + +# class TestOperators(ManagerTestBase): +# """Tests the behavior of different filter operators.""" + +# def setUp(self): +# """Creates the database, the :class:`~flask.Flask` object, the +# :class:`~flask.ext.restless.manager.APIManager` for that application, +# and creates the ReSTful API endpoints for the models used in the test +# methods. + +# """ +# super(TestOperators, self).setUp() + +# class Person(self.Base): +# __tablename__ = 'person' +# id = Column(Integer, primary_key=True) + +# self.Person = Person +# self.Base.metadata.create_all() +# self.manager.create_api(Person) + +# # TODO Refactor this method out of this and the previous class. +# def search(self, url, filters=None, single=None): +# """Convenience function for performing a filtered :http:method:`get` +# request. + +# `url` is the ``path`` part of the URL to which the request will be +# sent. + +# If `filters` is specified, it must be a Python list containing filter +# objects. It specifies how to set the ``filter[objects]`` query +# parameter. + +# If `single` is specified, it must be a Boolean. It specifies how to set +# the ``filter[single]`` query parameter. + +# """ +# if filters is None: +# filters = [] +# target_url = '{0}?filter[objects]={1}'.format(url, dumps(filters)) +# if single is not None: +# target_url += '&filter[single]={0}'.format(1 if single else 0) +# return self.app.get(target_url) + +# def tearDown(self): +# """Drops all tables from the temporary database.""" +# self.Base.metadata.drop_all() diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 00000000..effe1f84 --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,133 @@ +""" + tests.test_functions + ~~~~~~~~~~~~~~~~ + + Provides unit tests for function evaluation endpoints. + + :copyright: 2011 by Lincoln de Sousa + :copyright: 2012, 2013, 2014, 2015 Jeffrey Finkelstein + and contributors. + :license: GNU AGPLv3+ or BSD + +""" + + +class TestFunctionEvaluation(ManagerTestBase): + """Unit tests for the :class:`flask_restless.views.FunctionAPI` class.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`testapp.Person` and + :class:`testapp.Computer` models. + + """ + super(TestFunctionAPI, self).setUp() + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + age = Column(Integer) + + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Person, allow_functions=True) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_multiple_functions(self): + """Test that the :http:get:`/api/eval/person` endpoint returns the + result of evaluating multiple functions. + + """ + person1 = self.Person(age=10) + person2 = self.Person(age=15) + person3 = self.Person(age=20) + self.session.add_all([person1, person2, person3]) + self.session.commit() + functions = [dict(name='sum', field='age'), + dict(name='avg', field='age'), + dict(name='count', field='id')] + query = dumps(functions) + response = self.app.get('/api/eval/person?functions={0}'.format(query)) + assert response.status_code == 200 + document = loads(response.data) + results = document['data'] + assert results[0] == 45.0 + assert results[1] == 15.0 + assert results[2] == 3 + + def test_no_query(self): + """Tests that a request to the function evaluation endpoint with no + query parameter yields an error response. + + """ + response = self.app.get('/api/eval/person') + assert response.status_code == 400 + # TODO check error message + + def test_empty_query(self): + """Tests that a request to the function evaluation endpoint with an + empty functions query yields an error response. + + """ + response = self.app.get('/api/eval/person?functions=') + assert response.status_code == 400 + + def test_no_functions(self): + """Tests that if no functions are defined, an empty response is + returned. + + """ + response = self.app.get('/api/eval/person?functions=[]') + assert response.status_code == 204 + document = loads(response.data) + results = document['data'] + assert results == [] + + def test_missing_function_name(self): + functions = [dict(field='age')] + query = dumps(functions) + response = self.app.get('/api/eval/person?functions={0}'.format(query)) + assert response.status_code == 400 + # TODO check error message here + + def test_missing_field_name(self): + functions = [dict(name='sum')] + query = dumps(functions) + response = self.app.get('/api/eval/person?functions={0}'.format(query)) + assert response.status_code == 400 + # TODO check error message here + + def test_bad_field_name(self): + functions = [dict(name='sum', field='bogus')] + query = dumps(functions) + response = self.app.get('/api/eval/person?functions={0}'.format(query)) + assert response.status_code == 400 + # TODO check error message here + + def test_bad_function_name(self): + """Tests that an unknown function name yields an error response.""" + functions = [dict(name='bogus', field='age')] + query = dumps(functions) + response = self.app.get('/api/eval/person?functions={0}'.format(query)) + assert response.status_code == 400 + # TODO check error message here + + def test_jsonp(self): + """Test for JSON-P callbacks.""" + person = self.Person(age=10) + self.session.add(person) + self.session.commit() + functions = [dict(name='sum', field='age')] + response = self.app.get('/api/eval/person?functions=[{0}]' + '&callback=foo'.format(dumps(functions))) + assert response.status_code == 200 + assert response.mimetype == 'application/javascript' + assert response.data.startswith(b'foo(') + assert response.data.endswith(b')') + document = loads(response.data[4:-1]) + results = document['data'] + assert results[0] == 10.0 diff --git a/tests/test_jsonapi.py b/tests/test_jsonapi.py new file mode 100644 index 00000000..36eb9a83 --- /dev/null +++ b/tests/test_jsonapi.py @@ -0,0 +1,2267 @@ +""" + tests.test_jsonapi + ~~~~~~~~~~~~~~~~~~ + + Provides tests ensuring that Flask-Restless meets the requirements of the + `JSON API`_ standard. + + .. _JSON API: http://jsonapi.org + + :copyright: 2015 Jeffrey Finkelstein and + contributors. + :license: GNU AGPLv3+ or BSD + +""" +from urllib.parse import urlparse +from uuid import uuid1 + +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import Float +from sqlalchemy import ForeignKey +from sqlalchemy import Unicode +from sqlalchemy.orm import relationship + +from flask.ext.restless import CONTENT_TYPE + +from .helpers import dumps +from .helpers import loads +from .helpers import ManagerTestBase +from .helpers import GUID + + +class TestDocumentStructure(ManagerTestBase): + """Tests corresponding to the `Document Structure`_ section of the JSON API + specification. + + .. _Document Structure: http://jsonapi.org/format/#document-structure-resource-relationships + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestDocumentStructure, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person') + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + articles = relationship('Article') + + self.Article = Article + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Article) + self.manager.create_api(Person) + + # TODO refactor this so that it lives in a superclass. + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_get_primary_data(self): + """Tests that the top-level key in a response is ``data``.""" + response = self.app.get('/api/person') + assert response.status_code == 200 + assert 'data' in loads(response.data) + + def test_errors_top_level_key(self): + """Tests that errors appear under a top-level key ``errors``.""" + response = self.app.get('/api/person/boguskey') + data = loads(response.data) + assert 'errors' in data + + def test_no_other_top_level_keys(self): + """Tests that no there are no top-level keys in the response other than + the allowed ones. + + """ + response = self.app.get('/api/person') + data = loads(response.data) + assert set(data) <= {'data', 'errors', 'links', 'linked', 'meta'} + + def test_resource_attributes(self): + """Test that a resource has the required top-level keys.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1') + person = loads(response.data)['data'] + assert person['id'] == '1' + assert person['type'] == 'person' + + def test_self_link(self): + """Tests that a request to a self link responds with the same object. + + For more information, see the `Resource URLs`_ section of the JSON API + specification. + + .. _Resource URLs: http://jsonapi.org/format/#document-structure-resource-urls + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1') + document1 = loads(response.data) + person = document1['data'] + selfurl = person['links']['self'] + # The Flask test client doesn't need the `netloc` part of the URL. + path = urlparse(selfurl).path + response = self.app.get(path) + document2 = loads(response.data) + assert document1 == document2 + + def test_self_relationship_url(self): + """Tests that a link object correctly identifies its own relationship + URL. + + For more information, see the `Resource Relationships`_ section of the + JSON API specification. + + .. _Resource Relationships: http://jsonapi.org/format/#document-structure-resource-relationships + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + response = self.app.get('/api/article/1') + article = loads(response.data)['data'] + relationship_url = article['links']['author']['self'] + assert relationship_url.endswith('/api/article/1/links/author') + + def test_related_resource_url_to_one(self): + """Tests that the related resource URL in a to-one relationship + correctly identifies the related resource. + + For more information, see the `Resource Relationships`_ section of the + JSON API specification. + + .. _Resource Relationships: http://jsonapi.org/format/#document-structure-resource-relationships + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + # Get a resource that has links. + response = self.app.get('/api/article/1') + article = loads(response.data)['data'] + # Get the related resource URL. + resource_url = article['links']['author']['resource'] + # The Flask test client doesn't need the `netloc` part of the URL. + path = urlparse(resource_url).path + # Fetch the resource at the related resource URL. + response = self.app.get(path) + document = loads(response.data) + actual_person = document['data'] + # Compare it with what we expect to get. + response = self.app.get('/api/person/1') + expected_person = loads(response.data)['data'] + assert actual_person == expected_person + + def test_related_resource_url_to_many(self): + """Tests that the related resource URL in a to-many relationship + correctly identifies the related resource. + + For more information, see the `Resource Relationships`_ section of the + JSON API specification. + + .. _Resource Relationships: http://jsonapi.org/format/#document-structure-resource-relationships + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + # Get a resource that has links. + response = self.app.get('/api/person/1') + document = loads(response.data) + person = document['data'] + # Get the related resource URL. + resource_url = person['links']['articles']['resource'] + # The Flask test client doesn't need the `netloc` part of the URL. + path = urlparse(resource_url).path + # Fetch the resource at the related resource URL. + response = self.app.get(path) + document = loads(response.data) + actual_articles = document['data'] + # Compare it with what we expect to get. + # + # TODO To make this test more robust, filter by `article.author == 1`. + response = self.app.get('/api/article') + document = loads(response.data) + expected_articles = document['data'] + assert actual_articles == expected_articles + + def test_link_object(self): + """Tests for relations as resource URLs.""" + # TODO configure the api manager here + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1') + person = loads(response.data)['data'] + articles = person['links']['articles'] + # A link object must contain at least one of 'self', 'resource', + # linkage to a compound document, or 'meta'. + assert articles['self'].endswith('/api/person/1/links/articles') + assert articles['resource'].endswith('/api/person/1/articles') + # TODO should also include pagination links + + def test_compound_document_to_many(self): + """Tests for getting linked resources from a homogeneous to-many + relationship in a compound document. + + For more information, see the `Compound Documents`_ section of the JSON + API specification. + + .. _Compound Documents: http://jsonapi.org/format/#document-structure-compound-documents + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + person.articles = [article1, article2] + self.session.add_all([person, article1, article2]) + self.session.commit() + # For a homogeneous to-many relationship, we should have a 'type' and + # an 'ids' key. + response = self.app.get('/api/person/1?include=articles') + document = loads(response.data) + person = document['data'] + articles = person['links']['articles'] + assert articles['type'] == 'article' + assert ['1', '2'] == sorted(articles['ids']) + linked = document['linked'] + # Sort the links on their IDs, then get the two linked articles. + linked_article1, linked_article2 = sorted(linked, + key=lambda c: c['id']) + assert linked_article1['type'] == 'article' + assert linked_article1['id'] == '1' + assert linked_article2['type'] == 'article' + assert linked_article2['id'] == '2' + + def test_compound_document_to_one(self): + """Tests for getting linked resources from a to-one relationship in a + compound document. + + For more information, see the `Compound Documents`_ section of the JSON + API specification. + + .. _Compound Documents: http://jsonapi.org/format/#document-structure-compound-documents + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + # For a to-one relationship, we should have a 'type' and an 'id' key. + response = self.app.get('/api/article/1?include=author') + document = loads(response.data) + article = document['data'] + author = article['links']['author'] + assert author['type'] == 'person' + assert author['id'] == 1 + linked = document['linked'] + linked_person = linked[0] + assert linked_person['type'] == 'person' + assert linked_person['id'] == '1' + + def test_compound_document_many_types(self): + """Tests for getting linked resources of multiple types in a compound + document. + + """ + # For example, get articles and projects of a person. + assert False, 'Not implemented' + + def test_top_level_self_link(self): + """Tests that there is a top-level links object containing a self link. + + For more information, see the `Top-level Link`_ section of the JSON API + specification. + + .. _Top-level links: http://jsonapi.org/format/#document-structure-top-level-links + + """ + response = self.app.get('/api/person') + document = loads(response.data) + links = document['links'] + assert links['self'].endswith('/api/person') + + def test_top_level_pagination_link(self): + """Tests that there are top-level pagination links by default. + + For more information, see the `Top-level Link`_ section of the JSON API + specification. + + .. _Top-level links: http://jsonapi.org/format/#document-structure-top-level-links + + """ + response = self.app.get('/api/person') + document = loads(response.data) + links = document['links'] + assert 'first' in links + assert 'last' in links + assert 'prev' in links + assert 'next' in links + + +class TestPagination(ManagerTestBase): + + def setUp(self): + super(TestPagination, self).setUp() + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Person) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_no_client_parameters(self): + """Tests that a request without pagination query parameters returns the + first page of the collection. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + response = self.app.get('/api/person') + document = loads(response.data) + pagination = document['links'] + assert '/api/person?' in pagination['first'] + assert 'page[number]=1' in pagination['first'] + assert '/api/person?' in pagination['last'] + assert 'page[number]=3' in pagination['last'] + assert pagination['prev'] is None + assert '/api/person?' in pagination['next'] + assert 'page[number]=2' in pagination['next'] + assert len(document['data']) == 10 + + def test_client_page_and_size(self): + """Tests that a request that specifies both page number and page size + returns the correct page of the collection. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + response = self.app.get('/api/person?page[number]=2&page[size]=3') + document = loads(response.data) + pagination = document['links'] + assert '/api/person?' in pagination['first'] + assert 'page[number]=1' in pagination['first'] + assert '/api/person?' in pagination['last'] + assert 'page[number]=9' in pagination['last'] + assert '/api/person?' in pagination['prev'] + assert 'page[number]=1' in pagination['prev'] + assert '/api/person?' in pagination['next'] + assert 'page[number]=3' in pagination['next'] + assert len(document['data']) == 3 + + def test_client_number_only(self): + """Tests that a request that specifies only the page number returns the + correct page with the default page size. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + response = self.app.get('/api/person?page[number]=2') + document = loads(response.data) + pagination = document['links'] + assert '/api/person?' in pagination['first'] + assert 'page[number]=1' in pagination['first'] + assert '/api/person?' in pagination['last'] + assert 'page[number]=3' in pagination['last'] + assert '/api/person?' in pagination['prev'] + assert 'page[number]=1' in pagination['prev'] + assert '/api/person?' in pagination['next'] + assert 'page[number]=3' in pagination['next'] + assert len(document['data']) == 10 + + def test_client_size_only(self): + """Tests that a request that specifies only the page size returns the + first page with the requested page size. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + response = self.app.get('/api/person?page[size]=5') + document = loads(response.data) + pagination = document['links'] + assert '/api/person?' in pagination['first'] + assert 'page[number]=1' in pagination['first'] + assert '/api/person?' in pagination['last'] + assert 'page[number]=5' in pagination['last'] + assert pagination['prev'] is None + assert '/api/person?' in pagination['next'] + assert 'page[number]=2' in pagination['next'] + assert len(document['data']) == 5 + + def test_short_page(self): + """Tests that a request that specifies the last page may get fewer + resources than the page size. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + response = self.app.get('/api/person?page[number]=3') + document = loads(response.data) + pagination = document['links'] + assert '/api/person?' in pagination['first'] + assert 'page[number]=1' in pagination['first'] + assert '/api/person?' in pagination['last'] + assert 'page[number]=3' in pagination['last'] + assert '/api/person?' in pagination['prev'] + assert 'page[number]=2' in pagination['prev'] + assert pagination['next'] is None + assert len(document['data']) == 5 + + def test_server_page_size(self): + """Tests for setting the default page size on the server side. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + self.manager.create_api(self.Person, url_prefix='/api2', page_size=5) + response = self.app.get('/api2/person?page[number]=3') + document = loads(response.data) + pagination = document['links'] + assert '/api2/person?' in pagination['first'] + assert 'page[number]=1' in pagination['first'] + assert '/api2/person?' in pagination['last'] + assert 'page[number]=5' in pagination['last'] + assert '/api2/person?' in pagination['prev'] + assert 'page[number]=2' in pagination['prev'] + assert '/api2/person?' in pagination['next'] + assert 'page[number]=4' in pagination['next'] + assert len(document['data']) == 5 + + def test_disable_pagination(self): + """Tests for disabling default pagination on the server side. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + self.manager.create_api(self.Person, url_prefix='/api2', page_size=0) + response = self.app.get('/api2/person') + document = loads(response.data) + pagination = document['links'] + assert 'first' not in pagination + assert 'last' not in pagination + assert 'prev' not in pagination + assert 'next' not in pagination + assert len(document['data']) == 25 + + def test_disable_pagination_ignore_client(self): + """Tests that disabling default pagination on the server side ignores + client page number requests. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + self.manager.create_api(self.Person, url_prefix='/api2', page_size=0) + response = self.app.get('/api2/person?page[number]=2') + document = loads(response.data) + pagination = document['links'] + assert 'first' not in pagination + assert 'last' not in pagination + assert 'prev' not in pagination + assert 'next' not in pagination + assert len(document['data']) == 25 + # TODO Should there be an error here? + + def test_max_page_size(self): + """Tests that the client cannot exceed the maximum page size. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + self.manager.create_api(self.Person, url_prefix='/api2', + max_page_size=15) + response = self.app.get('/api2/person?page[size]=20') + assert response.status_code == 400 + # TODO check the error message here. + + def test_negative_page_size(self): + """Tests that the client cannot specify a negative page size. + + For more information, see the `Pagination`_ section of the JSON API + specification. + + .. _Pagination: http://jsonapi.org/format/#fetching-pagination + + """ + response = self.app.get('/api/person?page[size]=-1') + assert response.status_code == 400 + # TODO check the error message here. + + def test_headers(self): + """Tests that paginated requests come with ``Link`` headers. + + (This is not part of the JSON API standard, but should live with the + other pagination test methods anyway.) + + """ + people = [self.Person() for i in range(25)] + self.session.add_all(people) + self.session.commit() + response = self.app.get('/api/person?page[number]=4&page[size]=3') + links = response.headers.getlist('Link') + assert any('/api/person?page[number]=1&page[size]=3>; rel="first"' in l + for l in links) + assert any('/api/person?page[number]=9&page[size]=3>; rel="last"' in l + for l in links) + assert any('/api/person?page[number]=3&page[size]=3>; rel="prev"' in l + for l in links) + assert any('/api/person?page[number]=5&page[size]=3>; rel="next"' in l + for l in links) + + +class TestFetchingResources(ManagerTestBase): + """Tests corresponding to the `Fetching Resources`_ section of the JSON API + specification. + + .. _Fetching Resources: http://jsonapi.org/format/#fetching + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestFetchingResources, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + title = Column(Unicode) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person') + + class Comment(self.Base): + __tablename__ = 'comment' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person') + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + age = Column(Integer) + other = Column(Float) + comments = relationship('Comment') + articles = relationship('Article') + + self.Article = Article + self.Comment = Comment + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Article) + self.manager.create_api(Person) + # HACK Need to create APIs for these other models because otherwise + # we're not able to create the link URLs to them. + # + # TODO Fix this by simply not creating links to related models for + # which no API has been made. + self.manager.create_api(Comment) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_correct_accept_header(self): + """Tests that the server responds with a resource if the ``Accept`` + header specifies the JSON API media type. + + For more information, see the `Fetching Resources`_ section of the JSON + API specification. + + .. _Fetching Resources: http://jsonapi.org/format/#fetching + + """ + # The fixtures for this test class set up the correct `Accept` header + # for all requests from the test client. + response = self.app.get('/api/person') + assert response.status_code == 200 + assert response.mimetype == CONTENT_TYPE + + def test_incorrect_accept_header(self): + """Tests that the server responds with an :http:status:`415` if the + ``Accept`` header is incorrect. + + For more information, see the `Fetching Resources`_ section of the JSON + API specification. + + .. _Fetching Resources: http://jsonapi.org/format/#fetching + + """ + headers = dict(Accept='application/json') + response = self.app.get('/api/person', headers=headers) + assert response.status_code == 406 + assert response.mimetype == CONTENT_TYPE + + def test_missing_accept_header(self): + """Tests that the server responds with an :http:status:`415` if the + ``Accept`` header is missing. + + For more information, see the `Fetching Resources`_ section of the JSON + API specification. + + .. _Fetching Resources: http://jsonapi.org/format/#fetching + + """ + headers = dict(Accept=None) + response = self.app.get('/api/person', headers=headers) + assert response.status_code == 406 + assert response.mimetype == CONTENT_TYPE + + def test_to_many(self): + """Test for fetching resources from a to-many related resource URL.""" + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + person.articles = [article1, article2] + self.session.add_all([person, article1, article2]) + self.session.commit() + response = self.app.get('/api/person/1/articles') + assert response.status_code == 200 + document = loads(response.data) + articles = document['data'] + assert ['1', '2'] == sorted(c['id'] for c in articles) + + def test_to_one(self): + """Test for fetching resources from a to-one related resource URL.""" + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + response = self.app.get('/api/article/1/author') + document = loads(response.data) + person = document['data'] + assert person['id'] == '1' + + def test_default_inclusion(self): + """Tests that by default, Flask-Restless includes no information in + compound documents. + + For more information, see the `Inclusion of Linked Resources`_ section + of the JSON API specification. + + .. _Inclusion of Linked Resources: http://jsonapi.org/format/#fetching-includes + + """ + person = self.Person(id=1) + article = self.Article(id=1) + person.articles = [article] + self.session.add_all([person, article]) + self.session.commit() + # By default, no links will be included at the top level of the + # document. + response = self.app.get('/api/person/1') + document = loads(response.data) + person = document['data'] + articleids = person['links']['articles']['ids'] + assert articleids == ['1'] + assert 'linked' not in document + + def test_set_default_inclusion(self): + """Tests that the user can specify default compound document + inclusions when creating an API. + + For more information, see the `Inclusion of Linked Resources`_ section + of the JSON API specification. + + .. _Inclusion of Linked Resources: http://jsonapi.org/format/#fetching-includes + + """ + person = self.Person(id=1) + article = self.Article(id=1) + person.articles = [article] + self.session.add_all([person, article]) + self.session.commit() + self.manager.create_api(self.Person, includes=['articles'], + url_prefix='/api2') + # In the alternate API, articles are included by default in compound + # documents. + response = self.app.get('/api2/person/1') + document = loads(response.data) + person = document['data'] + linked = document['linked'] + articleids = person['links']['articles']['ids'] + assert articleids == ['1'] + assert linked[0]['type'] == 'article' + assert linked[0]['id'] == '1' + + def test_include(self): + """Tests that the client can specify which linked relations to include + in a compound document. + + For more information, see the `Inclusion of Linked Resources`_ section + of the JSON API specification. + + .. _Inclusion of Linked Resources: http://jsonapi.org/format/#fetching-includes + + """ + person = self.Person(id=1, name='foo') + article1 = self.Article(id=1) + article2 = self.Article(id=2) + comment = self.Comment() + person.articles = [article1, article2] + person.comments = [comment] + self.session.add_all([person, comment, article1, article2]) + self.session.commit() + response = self.app.get('/api/person/1?include=articles') + assert response.status_code == 200 + document = loads(response.data) + linked = document['linked'] + # If a client supplied an include request parameter, no other types of + # objects should be included. + assert all(c['type'] == 'article' for c in linked) + assert ['1', '2'] == sorted(c['id'] for c in linked) + + def test_include_multiple(self): + """Tests that the client can specify multiple linked relations to + include in a compound document. + + For more information, see the `Inclusion of Linked Resources`_ section + of the JSON API specification. + + .. _Inclusion of Linked Resources: http://jsonapi.org/format/#fetching-includes + + """ + person = self.Person(id=1, name='foo') + article = self.Article(id=2) + comment = self.Comment(id=3) + person.articles = [article] + person.comments = [comment] + self.session.add_all([person, comment, article]) + self.session.commit() + response = self.app.get('/api/person/1?include=articles,comments') + assert response.status_code == 200 + document = loads(response.data) + # Sort the linked objects by type; 'article' comes before 'comment' + # lexicographically. + linked = sorted(document['linked'], key=lambda x: x['type']) + linked_article, linked_comment = linked + assert linked_article['type'] == 'article' + assert linked_article['id'] == '2' + assert linked_comment['type'] == 'comment' + assert linked_comment['id'] == '3' + + def test_include_dot_separated(self): + """Tests that the client can specify resources linked to other + resources to include in a compound document. + + For more information, see the `Inclusion of Linked Resources`_ section + of the JSON API specification. + + .. _Inclusion of Linked Resources: http://jsonapi.org/format/#fetching-includes + + """ + assert False, 'Not implemented' + + def test_sparse_fieldsets(self): + """Tests that the client can specify which fields to return in the + response of a fetch request for a single object. + + For more information, see the `Sparse Fieldsets`_ section + of the JSON API specification. + + .. _Sparse Fieldsets: http://jsonapi.org/format/#fetching-sparse-fieldsets + + """ + person = self.Person(id=1, name='foo', age=99) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1?fields[person]=id,name') + document = loads(response.data) + person = document['data'] + # ID and type must always be included. + assert ['id', 'name', 'type'] == sorted(person) + + def test_sparse_fieldsets_id_and_type(self): + """Tests that the ID and type of the resource are always included in a + response from a request for sparse fieldsets, regardless of what the + client requests. + + For more information, see the `Sparse Fieldsets`_ section + of the JSON API specification. + + .. _Sparse Fieldsets: http://jsonapi.org/format/#fetching-sparse-fieldsets + + """ + person = self.Person(id=1, name='foo', age=99) + self.session.add(person) + self.session.commit() + response = self.app.get('/api/person/1?fields[person]=id') + document = loads(response.data) + person = document['data'] + # ID and type must always be included. + assert ['id', 'type'] == sorted(person) + + def test_sparse_fieldsets_collection(self): + """Tests that the client can specify which fields to return in the + response of a fetch request for a collection of objects. + + For more information, see the `Sparse Fieldsets`_ section + of the JSON API specification. + + .. _Sparse Fieldsets: http://jsonapi.org/format/#fetching-sparse-fieldsets + + """ + person1 = self.Person(id=1, name='foo', age=99) + person2 = self.Person(id=2, name='bar', age=80) + self.session.add_all([person1, person2]) + self.session.commit() + response = self.app.get('/api/person?fields[person]=id,name') + document = loads(response.data) + people = document['data'] + assert all(['id', 'name', 'type'] == sorted(p) for p in people) + + def test_sparse_fieldsets_multiple_types(self): + """Tests that the client can specify which fields to return in the + response with multiple types specified. + + For more information, see the `Sparse Fieldsets`_ section + of the JSON API specification. + + .. _Sparse Fieldsets: http://jsonapi.org/format/#fetching-sparse-fieldsets + + """ + article = self.Article(id=1, title='bar') + person = self.Person(id=1, name='foo', age=99, articles=[article]) + self.session.add_all([person, article]) + self.session.commit() + # Person objects should only have ID and name, while article objects + # should only have ID. + url = ('/api/person/1?include=articles' + '&fields[person]=id,name,articles&fields[article]=id') + response = self.app.get(url) + document = loads(response.data) + person = document['data'] + linked = document['linked'] + # We requested 'id', 'name', and 'articles'; 'id' and 'type' must + # always be present, and 'articles' comes under a 'links' key. + assert ['id', 'links', 'name', 'type'] == sorted(person) + assert ['articles'] == sorted(person['links']) + # We requested only 'id', but 'type' must always appear as well. + assert all(['id', 'type'] == sorted(article) for article in linked) + + def test_sort_increasing(self): + """Tests that the client can specify the fields on which to sort the + response in increasing order. + + For more information, see the `Sorting`_ section of the JSON API + specification. + + .. _Sorting: http://jsonapi.org/format/#fetching-sorting + + """ + person1 = self.Person(name='foo', age=20) + person2 = self.Person(name='bar', age=10) + person3 = self.Person(name='baz', age=30) + self.session.add_all([person1, person2, person3]) + self.session.commit() + # The plus sign must be URL-encoded as ``%2B``. + response = self.app.get('/api/person?sort=%2Bage') + document = loads(response.data) + people = document['data'] + age1, age2, age3 = (p['age'] for p in people) + assert age1 <= age2 <= age3 + + def test_sort_decreasing(self): + """Tests that the client can specify the fields on which to sort the + response in decreasing order. + + For more information, see the `Sorting`_ section of the JSON API + specification. + + .. _Sorting: http://jsonapi.org/format/#fetching-sorting + + """ + person1 = self.Person(name='foo', age=20) + person2 = self.Person(name='bar', age=10) + person3 = self.Person(name='baz', age=30) + self.session.add_all([person1, person2, person3]) + self.session.commit() + response = self.app.get('/api/person?sort=-age') + document = loads(response.data) + people = document['data'] + age1, age2, age3 = (p['age'] for p in people) + assert age1 >= age2 >= age3 + + def test_sort_multiple_fields(self): + """Tests that the client can sort by multiple fields. + + For more information, see the `Sorting`_ section of the JSON API + specification. + + .. _Sorting: http://jsonapi.org/format/#fetching-sorting + + """ + person1 = self.Person(name='foo', age=99) + person2 = self.Person(name='bar', age=99) + person3 = self.Person(name='baz', age=80) + person4 = self.Person(name='xyzzy', age=80) + self.session.add_all([person1, person2, person3, person4]) + self.session.commit() + # Sort by age, decreasing, then by name, increasing. + # + # The plus sign must be URL-encoded as ``%2B``. + response = self.app.get('/api/person?sort=-age,%2Bname') + document = loads(response.data) + people = document['data'] + p1, p2, p3, p4 = people + assert p1['age'] == p2['age'] >= p3['age'] == p4['age'] + assert p1['name'] <= p2['name'] + assert p3['name'] <= p4['name'] + + def test_sort_relationship_attributes(self): + """Tests that the client can sort by relationship attributes. + + For more information, see the `Sorting`_ section of the JSON API + specification. + + .. _Sorting: http://jsonapi.org/format/#fetching-sorting + + """ + person1 = self.Person(age=20) + person2 = self.Person(age=10) + person3 = self.Person(age=30) + article1 = self.Article(id=1, author=person1) + article2 = self.Article(id=2, author=person2) + article3 = self.Article(id=3, author=person3) + self.session.add_all([person1, person2, person3, article1, article2, + article3]) + self.session.commit() + # The plus sign must be URL-encoded as ``%2B``. + response = self.app.get('/api/article?sort=%2Bauthor.age') + document = loads(response.data) + articles = document['data'] + assert ['2', '1', '3'] == [c['id'] for c in articles] + + def test_filtering(self): + """Tests that the client can specify filters. + + For more information, see the `Filtering`_ section of the JSON API + specification. + + .. _Filtering: http://jsonapi.org/format/#fetching-filtering + + """ + assert False, 'Not implemented' + + +class TestCreatingResources(ManagerTestBase): + """Tests corresponding to the `Creating Resources`_ section of the JSON API + specification. + + .. _Creating Resources: http://jsonapi.org/format/#crud-creating + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestCreatingResources, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(GUID, primary_key=True) + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + + self.Article = Article + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Person, methods=['POST']) + self.manager.create_api(Article, methods=['POST'], + allow_client_generated_ids=True) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_create(self): + """Tests that the client can create a single resource. + + For more information, see the `Creating Resources`_ section of the JSON + API specification. + + .. _Creating Resources: http://jsonapi.org/format/#crud-creating + + """ + data = dict(data=dict(type='person', name='foo')) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 201 + location = response.headers['Location'] + # TODO Technically, this test shouldn't know beforehand where the + # location of the created object will be. We are testing implementation + # here, assuming that the implementation of the server creates a new + # Person object with ID 1, which is bad style. + assert location.endswith('/api/person/1') + document = loads(response.data) + person = document['data'] + assert person['type'] == 'person' + assert person['id'] == '1' + assert person['name'] == 'foo' + assert person['links']['self'] == location + + def test_without_type(self): + """Tests for an error response if the client fails to specify the type + of the object to create. + + For more information, see the `Creating Resources`_ section of the JSON + API specification. + + .. _Creating Resources: http://jsonapi.org/format/#crud-creating + + """ + data = dict(data=dict(name='foo')) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 400 + # TODO test for error details (for example, a message specifying that + # type is missing) + + def test_client_generated_id(self): + """Tests that the client can specify a UUID to become the ID of the + created object. + + For more information, see the `Client-Generated IDs`_ section of the + JSON API specification. + + .. _Client-Generated IDs: http://jsonapi.org/format/#crud-creating-client-ids + + """ + generated_id = uuid1() + data = dict(data=dict(type='article', id=generated_id)) + response = self.app.post('/api/article', data=dumps(data)) + # Our server always responds with 201 when a client-generated ID is + # specified. It does not return a 204. + # + # TODO should we reverse that and only return 204? + assert response.status_code == 201 + document = loads(response.data) + article = document['data'] + assert article['type'] == 'article' + assert article['id'] == str(generated_id) + + def test_client_generated_id_forbidden(self): + """Tests that the client can specify a UUID to become the ID of the + created object. + + For more information, see the `Client-Generated IDs`_ section of the + JSON API specification. + + .. _Client-Generated IDs: http://jsonapi.org/format/#crud-creating-client-ids + + """ + self.manager.create_api(self.Article, url_prefix='/api2', + methods=['POST']) + data = dict(data=dict(type='article', id=uuid1())) + response = self.app.post('/api2/article', data=dumps(data)) + assert response.status_code == 403 + # TODO test for error details (for example, a message specifying that + # client-generated IDs are not allowed). + + def test_type_conflict(self): + """Tests that if a client specifies a type that does not match the + endpoint, a :http:status:`409` is returned. + + For more information, see the `409 Conflict`_ section of the JSON API + specification. + + .. _409 Conflict: http://jsonapi.org/format/#crud-creating-responses-409 + + """ + + data = dict(data=dict(type='bogustype', name='foo')) + response = self.app.post('/api/person', data=dumps(data)) + assert response.status_code == 409 + # TODO test for error details (for example, a message specifying that + # client-generated IDs are not allowed). + + def test_id_conflict(self): + """Tests that if a client specifies a client-generated ID that already + exists, a :http:status:`409` is returned. + + For more information, see the `409 Conflict`_ section of the JSON API + specification. + + .. _409 Conflict: http://jsonapi.org/format/#crud-creating-responses-409 + + """ + generated_id = uuid1() + self.session.add(self.Article(id=generated_id)) + self.session.commit() + data = dict(data=dict(type='article', id=generated_id)) + response = self.app.post('/api/article', data=dumps(data)) + assert response.status_code == 409 + # TODO test for error details (for example, a message specifying that + # client-generated IDs are not allowed). + + +class TestUpdatingResources(ManagerTestBase): + """Tests corresponding to the `Updating Resources`_ section of the JSON API + specification. + + .. _Updating Resources: http://jsonapi.org/format/#crud-updating + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestUpdatingResources, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person') + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + name = Column(Unicode, unique=True) + age = Column(Integer) + articles = relationship('Article') + + self.Article = Article + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Person, methods=['PUT']) + self.manager.create_api(Article, methods=['PUT']) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_update(self): + """Tests that the client can update a resource's attributes. + + For more information, see the `Updating a Resource's Attributes`_ + section of the JSON API specification. + + .. _Updating a Resource's Attributes: http://jsonapi.org/format/#crud-updating-resource-attributes + + """ + person = self.Person(id=1, name='foo', age=10) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', id='1', name='bar')) + response = self.app.put('/api/person/1', data=dumps(data)) + assert response.status_code == 204 + assert person.id == 1 + assert person.name == 'bar' + assert person.age == 10 + + def test_to_one(self): + """Tests that the client can update a resource's to-one relationships. + + For more information, see the `Updating a Resource's To-One Relationships`_ + section of the JSON API specification. + + .. _Updating a Resource's To-One Relationships: http://jsonapi.org/format/#crud-updating-resource-to-one-relationships + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + article = self.Article(id=1) + person1.articles = [article] + self.session.add_all([person1, person2, article]) + self.session.commit() + # Change the author of the article from person 1 to person 2. + data = {'data': + {'type': 'article', + 'id': '1', + 'links': + {'author': + {'type': 'person', 'id': '2'} + } + } + } + response = self.app.put('/api/article/1', data=dumps(data)) + assert response.status_code == 204 + assert article.author is person2 + + def test_remove_to_one(self): + """Tests that the client can remove a resource's to-one relationship. + + For more information, see the `Updating a Resource's To-One Relationships`_ + section of the JSON API specification. + + .. _Updating a Resource's To-One Relationships: http://jsonapi.org/format/#crud-updating-resource-to-one-relationships + + """ + person = self.Person(id=1) + article = self.Article() + person.articles = [article] + self.session.add_all([person, article]) + self.session.commit() + # Change the author of the article to None. + data = {'data': + {'type': 'article', + 'id': '1', + 'links': + {'author': None} + } + } + response = self.app.put('/api/article/1', data=dumps(data)) + assert response.status_code == 204 + assert article.author is None + + def test_to_many(self): + """Tests that the client can update a resource's to-many relationships. + + For more information, see the `Updating a Resource's To-Many Relationships`_ + section of the JSON API specification. + + .. _Updating a Resource's To-Many Relationships: http://jsonapi.org/format/#crud-updating-resource-to-many-relationships + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + self.session.add_all([person, article1, article2]) + self.session.commit() + self.manager.create_api(self.Person, methods=['PUT'], + url_prefix='/api2', + allow_to_many_replacement=True) + data = {'data': + {'type': 'person', + 'id': '1', + 'links': + {'articles': + {'type': 'article', 'ids': ['1', '2']} + } + } + } + response = self.app.put('/api2/person/1', data=dumps(data)) + assert response.status_code == 204 + assert set(person.articles) == {article1, article2} + + def test_to_many_clear(self): + """Tests that the client can clear a resource's to-many relationships. + + For more information, see the `Updating a Resource's To-Many Relationships`_ + section of the JSON API specification. + + .. _Updating a Resource's To-Many Relationships: http://jsonapi.org/format/#crud-updating-resource-to-many-relationships + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + person.articles = [article1, article2] + self.session.add_all([person, article1, article2]) + self.session.commit() + self.manager.create_api(self.Person, methods=['PUT'], + url_prefix='/api2', + allow_to_many_replacement=True) + data = {'data': + {'type': 'person', + 'id': '1', + 'links': + {'articles': + {'type': 'article', 'ids': []} + } + } + } + response = self.app.put('/api2/person/1', data=dumps(data)) + assert response.status_code == 204 + assert person.articles == [] + + def test_to_many_forbidden(self): + """Tests that the client receives a :http:status:`403` if the server + has been configured to disallow full replacement of a to-many + relationship. + + For more information, see the `Updating a Resource's To-Many Relationships`_ + section of the JSON API specification. + + .. _Updating a Resource's To-Many Relationships: http://jsonapi.org/format/#crud-updating-resource-to-many-relationships + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = {'data': + {'type': 'person', + 'id': '1', + 'links': + {'articles': + {'type': 'article', 'ids': []} + } + } + } + response = self.app.put('/api/person/1', data=dumps(data)) + assert response.status_code == 403 + + def test_other_modifications(self): + """Tests that if an update causes additional changes in the resource in + ways other than those specified by the client, the response has status + :http:status:`200` and includes the updated resource. + + For more information, see the `200 OK`_ section of the JSON API + specification. + + .. _200 OK: http://jsonapi.org/format/#crud-updating-responses-200 + + """ + assert False, 'Not implemented' + + def test_nonexistent(self): + """Tests that an attempt to update a nonexistent resource causes a + :http:status:`404` response. + + For more information, see the `404 Not Found`_ section of the JSON API + specification. + + .. _404 Not Found: http://jsonapi.org/format/#crud-updating-responses-404 + + """ + data = dict(data=dict(type='person', id='1')) + response = self.app.put('/api/person/1', data=dumps(data)) + assert response.status_code == 404 + + def test_nonexistent_relationship(self): + """Tests that an attempt to update a nonexistent resource causes a + :http:status:`404` response. + + For more information, see the `404 Not Found`_ section of the JSON API + specification. + + .. _404 Not Found: http://jsonapi.org/format/#crud-updating-responses-404 + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + self.manager.create_api(self.Person, methods=['PUT'], + url_prefix='/api2', + allow_to_many_replacement=True) + data = {'data': + {'type': 'person', + 'id': '1', + 'links': + {'articles': + {'type': 'article', 'ids': [1]} + } + } + } + response = self.app.put('/api2/person/1', data=dumps(data)) + assert response.status_code == 404 + # TODO test for error details + + def test_conflicting_attributes(self): + """Tests that an attempt to update a resource with a non-unique + attribute value where uniqueness is required causes a + :http:status:`409` response. + + For more information, see the `409 Conflict`_ section of the JSON API + specification. + + .. _409 Conflict: http://jsonapi.org/format/#crud-updating-responses-409 + + """ + person1 = self.Person(id=1, name='foo') + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + data = dict(data=dict(type='person', id='2', name='foo')) + response = self.app.put('/api/person/2', data=dumps(data)) + assert response.status_code == 409 + # TODO test for error details + + def test_conflicting_type(self): + """Tests that an attempt to update a resource with the wrong type + causes a :http:status:`409` response. + + For more information, see the `409 Conflict`_ section of the JSON API + specification. + + .. _409 Conflict: http://jsonapi.org/format/#crud-updating-responses-409 + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='bogus', id='1')) + response = self.app.put('/api/person/1', data=dumps(data)) + assert response.status_code == 409 + # TODO test for error details + + def test_conflicting_id(self): + """Tests that an attempt to update a resource with the wrong ID causes + a :http:status:`409` response. + + For more information, see the `409 Conflict`_ section of the JSON API + specification. + + .. _409 Conflict: http://jsonapi.org/format/#crud-updating-responses-409 + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', id='bogus')) + response = self.app.put('/api/person/1', data=dumps(data)) + assert response.status_code == 409 + # TODO test for error details + + +class TestUpdatingRelationships(ManagerTestBase): + """Tests corresponding to the `Updating Relationships`_ section of the JSON + API specification. + + .. _Updating Relationships: http://jsonapi.org/format/#crud-updating-relationships + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestUpdatingRelationships, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person') + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + articles = relationship('Article') + + self.Article = Article + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(self.Person, methods=['PUT', 'POST', 'DELETE']) + self.manager.create_api(self.Article, methods=['PUT']) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_to_one(self): + """Tests for updating a to-one relationship via a :http:method:`put` + request to a relationship URL. + + For more information, see the `Updating To-One Relationships`_ section + of the JSON API specification. + + .. _Updating To-One Relationships: http://jsonapi.org/format/#crud-updating-to-one-relationships + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + article = self.Article(id=1) + article.author = person1 + self.session.add_all([person1, person2, article]) + self.session.commit() + data = dict(data=dict(type='person', id='2')) + response = self.app.put('/api/article/1/links/author', + data=dumps(data)) + assert response.status_code == 204 + assert article.author is person2 + + def test_remove_to_one(self): + """Tests for removing a to-one relationship via a :http:method:`put` + request to a relationship URL. + + For more information, see the `Updating To-One Relationships`_ section + of the JSON API specification. + + .. _Updating To-One Relationships: http://jsonapi.org/format/#crud-updating-to-one-relationships + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + article = self.Article(id=1) + article.author = person1 + self.session.add_all([person1, person2, article]) + self.session.commit() + data = dict(data=None) + response = self.app.put('/api/article/1/links/author', + data=dumps(data)) + assert response.status_code == 204 + assert article.author is None + + def test_to_many(self): + """Tests for replacing a to-many relationship via a :http:method:`put` + request to a relationship URL. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + self.session.add_all([person, article1, article2]) + self.session.commit() + self.manager.create_api(self.Person, methods=['PUT'], + url_prefix='/api2', + allow_to_many_replacement=True) + data = dict(data=dict(type='article', ids=['1', '2'])) + response = self.app.put('/api2/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 204 + assert set(person.articles) == {article1, article2} + + def test_to_many_not_found(self): + """Tests that an attempt to replace a to-many relationship with a + related resource that does not exist yields an error response. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + article = self.Article(id=1) + self.session.add_all([person, article]) + self.session.commit() + self.manager.create_api(self.Person, methods=['PUT'], + url_prefix='/api2', + allow_to_many_replacement=True) + data = dict(data=dict(type='article', ids=['1', '2'])) + response = self.app.put('/api2/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 404 + # TODO test error messages + + def test_to_many_forbidden(self): + """Tests that full replacement of a to-many relationship is forbidden + by the server configuration, then the response is :http:status:`403`. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='article', ids=[])) + response = self.app.put('/api/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 403 + # TODO test error messages + + def test_to_many_append(self): + """Tests for appending to a to-many relationship via a + :http:method:`post` request to a relationship URL. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + self.session.add_all([person, article1, article2]) + self.session.commit() + data = dict(data=dict(type='article', ids=['1', '2'])) + response = self.app.post('/api/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 204 + assert set(person.articles) == {article1, article2} + + def test_to_many_preexisting(self): + """Tests for attempting to append an element that already exists in a + to-many relationship via a :http:method:`post` request to a + relationship URL. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + article = self.Article(id=1) + person.articles = [article] + self.session.add_all([person, article]) + self.session.commit() + data = dict(data=dict(type='article', ids=['1'])) + response = self.app.post('/api/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 204 + assert person.articles == [article] + + def test_to_many_delete(self): + """Tests for deleting from a to-many relationship via a + :http:method:`delete` request to a relationship URL. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + person.articles = [article1, article2] + self.session.add_all([person, article1, article2]) + self.session.commit() + self.manager.create_api(self.Person, methods=['DELETE'], + url_prefix='/api2', + allow_delete_from_to_many_relationships=True) + data = dict(data=dict(type='article', ids=['1'])) + response = self.app.delete('/api2/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 204 + assert person.articles == [article2] + + def test_to_many_delete_nonexistent(self): + """Tests for deleting a nonexistent member from a to-many relationship + via a :http:method:`delete` request to a relationship URL. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + person.articles = [article1] + self.session.add_all([person, article1, article2]) + self.session.commit() + self.manager.create_api(self.Person, methods=['DELETE'], + url_prefix='/api2', + allow_delete_from_to_many_relationships=True) + data = dict(data=dict(type='article', ids=['2'])) + response = self.app.delete('/api2/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 204 + assert person.articles == [article1] + + def test_to_many_delete_forbidden(self): + """Tests that attempting to delete from a to-many relationship via a + :http:method:`delete` request to a relationship URL when the server has + disallowed it yields a :http:status:`409` response. + + For more information, see the `Updating To-Many Relationships`_ section + of the JSON API specification. + + .. _Updating To-Many Relationships: http://jsonapi.org/format/#crud-updating-to-many-relationships + + """ + person = self.Person(id=1) + article = self.Article(id=1) + person.articles = [article] + self.session.add_all([person, article]) + self.session.commit() + data = dict(data=dict(type='article', ids=['1'])) + response = self.app.delete('/api/person/1/links/articles', + data=dumps(data)) + assert response.status_code == 403 + assert person.articles == [article] + + +class TestDeletingResources(ManagerTestBase): + """Tests corresponding to the `Deleting Resources`_ section of the JSON API + specification. + + .. _Deleting Resources: http://jsonapi.org/format/#crud-deleting + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + class. + + """ + # create the database + super(TestDeletingResources, self).setUp() + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(self.Person, methods=['DELETE']) + + def test_delete(self): + """Tests for deleting a resource. + + For more information, see the `Deleting Resources`_ section of the JSON + API specification. + + .. _Deleting Resources: http://jsonapi.org/format/#crud-deleting + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.delete('/api/person/1') + assert response.status_code == 204 + assert self.session.query(self.Person).count() == 0 + + def test_delete_nonexistent(self): + """Tests that deleting a nonexistent resource causes a + :http:status:`404`. + + For more information, see the `404 Not Found`_ section of the JSON API + specification. + + .. _404 Not Found: http://jsonapi.org/format/#crud-deleting-responses-404 + + """ + response = self.app.delete('/api/person/1') + assert response.status_code == 404 + + +# class TestJsonPatch(TestSupport): + +# def setUp(self): +# """Creates the database, the :class:`~flask.Flask` object, the +# :class:`~flask_restless.manager.APIManager` for that application, and +# creates the ReSTful API endpoints for the :class:`testapp.Person` and +# :class:`testapp.Computer` models. + +# """ +# # create the database +# super(TestJsonAPI, self).setUp() + +# # setup the URLs for the Person and Computer API +# self.manager.create_api(self.Person, methods=['PATCH']) +# self.manager.create_api(self.Computer, methods=['PATCH']) + +# def test_json_patch_header(self): +# self.session.add(self.Person()) +# self.session.commit() + +# # Requests must have the appropriate JSON Patch headers. +# response = self.app.patch('/api/person/1', +# content_type='application/vnd.api+json') +# assert response.status_code == 400 +# response = self.app.patch('/api/person/1', +# content_type='application/json') +# assert response.status_code == 400 + +# # TODO test bulk JSON Patch operations at the root level of the API. +# def test_json_patch_create(self): +# data = list(dict(op='add', path='/-', value=dict(name='foo'))) +# response = self.app.patch('/api/person', data=dumps(data)) +# assert response.status_code == 201 +# person = loads(response.data) +# assert person['name'] == 'foo' + +# def test_json_patch_update(self): +# person = self.Person(id=1, name='foo') +# self.session.add(person) +# self.session.commit() +# data = list(dict(op='replace', path='/name', value='bar')) +# response = self.app.patch('/api/person/1', data=dumps(data)) +# assert response.status_code == 204 +# assert person.name == 'bar' + +# def test_json_patch_to_one_relationship(self): +# person1 = self.Person(id=1) +# person2 = self.Person(id=2) +# computer = self.Computer(id=1) +# computer.owner = person1 +# self.session.add_all([person1, person2, computer]) +# self.session.commit() + +# # Change the owner of the computer from person 1 to person 2. +# data = list(dict(op='replace', path='', value='2')) +# response = self.app.patch('/api/computer/1/owner', data=dumps(data)) +# assert response.status_code == 204 +# assert computer.owner == person2 + +# def test_json_patch_remove_to_one_relationship(self): +# person = self.Person(id=1) +# computer = self.Computer(id=1) +# computer.owner = person +# self.session.add_all([person, computer]) +# self.session.commit() + +# # Change the owner of the computer from person 1 to person 2. +# data = list(dict(op='remove', path='')) +# response = self.app.patch('/api/computer/1/owner', data=dumps(data)) +# assert response.status_code == 204 +# assert person.computers == [] + +# def test_json_patch_to_many_relationship(self): +# person = self.Person(id=1) +# computer = self.Computer(id=1) +# self.session.add_all([person, computer]) +# self.session.commit() + +# # Add computer 1 to the list of computers owned by person 1. +# data = list(dict(op='add', path='/-', value='1')) +# response = self.app.patch('/api/person/1/computers', data=dumps(data)) +# assert response.status_code == 204 +# assert person.computers == [computer] + +# def test_json_patch_remove_to_many_relationship(self): +# person = self.Person(id=1) +# computer = self.Computer(id=1) +# person.computers = [computer] +# self.session.add_all([person, computer]) +# self.session.commit() + +# # Remove computer 1 to the list of computers owned by person 1. +# data = list(dict(op='remove', path='/1')) +# response = self.app.patch('/api/person/1/computers', data=dumps(data)) +# assert response.status_code == 204 +# assert person.computers == [] + +# def test_json_patch_delete(self): +# person = self.Person(id=1) +# self.session.add(person) +# self.session.commit() + +# # Remove the person. +# data = list(dict(op='remove', path='')) +# response = self.app.patch('/api/person/1', data=dumps(data)) +# assert response.status_code == 204 +# assert self.Person.query.count() == 0 + +# def test_json_patch_multiple(self): +# # Create multiple person instances with a single request. +# data = list(dict(op='add', path='/-', value=dict(name='foo')), +# dict(op='add', path='/-', value=dict(name='bar'))) +# response = self.app.patch('/api/person', data=dumps(data)) +# assert response.status_code == 200 +# assert response.content_type == 'application/json' +# data = loads(response.data) +# assert data[0]['person'][0]['name'] == 'foo' +# assert data[1]['person'][0]['name'] == 'bar' + +# class OldTests(TestSupport): + +# def setUp(self): +# """Creates the database, the :class:`~flask.Flask` object, the +# :class:`~flask_restless.manager.APIManager` for that application, and +# creates the ReSTful API endpoints for the :class:`TestSupport.Person` +# class. + +# """ +# super(OldTests, self).setUp() +# #self.manager.create_api(...) + +# def test_get(self): +# # Create a person object with multiple computers. +# person1 = self.Person(id=1, name='foo') +# person2 = self.Person(id=2, name='bar') +# computer1 = self.Computer(id=1) +# computer2 = self.Computer(id=2) +# computer3 = self.Computer(id=3) +# person1.computers = [computer1, computer2] +# self.session.add_all([person1, person2]) +# self.session.add_all([computer1, computer2, computer3]) +# self.session.commit() + +# # Get the person and ensure all its data and one-to-many links are +# # available. +# response = self.app.get('/api/person/1') +# assert response.status_code == 200 +# data = loads(response.data) +# person = data['person'] +# assert person['id'] == '1' +# assert person['name'] == 'foo' +# computerids = person['links']['computers'] +# assert sorted(['1', '2']) == sorted(computerids) + +# # A person without any computers should have an empty list there. +# response = self.app.get('/api/person/2') +# assert response.status_code == 200 +# data = loads(response.data) +# person = data['person'] +# assert person['id'] == '2' +# assert person['name'] == 'bar' +# assert [] == person['links']['computers'] + +# # Get one of the computers and ensure that its many-to-one link is the +# # ID of the person that owns it. +# response = self.app.get('/api/computer/1') +# assert response.status_code == 200 +# data = loads(response.data) +# computer = data['computer'] +# assert computer['id'] == '1' +# ownerid = computer['links']['owner'] +# assert '1' == ownerid + +# # A computer without an owner should have a null value there. +# response = self.app.get('/api/computer/3') +# assert response.status_code == 200 +# data = loads(response.data) +# computer = data['computer'] +# assert computer['id'] == '3' +# ownerid = computer['links']['owner'] +# assert None == ownerid + +# def test_resource_types(self): +# """Tests that each resource has a type and an ID.""" +# self.session.add_all([self.Person(id=1), self.Computer(id=1)]) +# self.session.commit() +# response = self.app.get('/api/person/1') +# person = loads(response.data)['person'] +# assert person['id'] == '1' +# assert person['type'] == 'person' +# response = self.app.get('/api/computer/1') +# person = loads(response.data)['computer'] +# assert person['id'] == '1' +# assert person['type'] == 'computer' + +# def test_self_links(self): +# person = self.Person(id=1) +# self.session.add(person) +# self.session.commit() +# response = self.app.get('/api/person/1') +# data = loads(response.data) +# person = data['person'] +# assert person['links']['self'].endswith('/api/person/1') + +# def test_self_links_in_relationship(self): +# person = self.Person(id=1) +# computer = self.Computer(id=1) +# person.computers = [computer] +# self.session.add_all([person, computer]) +# self.session.commit() +# response = self.app.get('/api/computer/1/links/owner') +# data = loads(response.data) +# person = data['person'] +# assert person['links']['self'].endswith('/api/person/1') + +# def test_top_level_links(self): +# # Create a person object with multiple computers. +# person = self.Person(id=1, name='foo') +# computer1 = self.Computer(id=1) +# computer2 = self.Computer(id=2) +# person.computers = [computer1, computer2] +# self.session.add_all([person, computer1, computer2]) +# self.session.commit() + +# # Check that the top-level document provides a link template to the +# # links to the one-to-many relationships. +# response = self.app.get('/api/person') +# assert response.status_code == 200 +# data = loads(response.data) +# # TODO Need to also test for `people.computers` if the collection name +# # is specified by the user in `create_api()`. +# template = data['links']['person.computers'] +# assert template.endswith('/api/person/{person.computers}') +# # TODO Test for compound documents. + +# def test_get_multiple_with_commas(self): +# person1 = self.Person(id=1) +# person2 = self.Person(id=2) +# self.session.add_all([person1, person2]) +# self.session.commit() + +# response = self.app.get('/api/person/1,2') +# assert response.status_code == 200 +# data = loads(response.data) +# assert sorted(['1', '2']) == sorted(p['id'] for p in data['person']) + +# def test_post_multiple(self): +# data = dict(person=[dict(name='foo', age=10), dict(name='bar')]) +# response = self.app.post('/api/person', data=dumps(data)) +# assert response.status_code == 201 +# people = loads(response.data)['person'] +# assert sorted(['foo', 'bar']) == sorted(p['name'] for p in people) +# # The server must respond with a Location header for each person. +# # +# # Sort the locations by primary key, which is the last character in the +# # Location URL. +# locations = sorted(response.headers.getlist('Location'), +# key=lambda s: s[-1]) +# assert locations[0].endswith('/api/person/1') +# assert locations[1].endswith('/api/person/2') + +# def test_put_multiple(self): +# person1 = self.Person(id=1, name='foo') +# person2 = self.Person(id=2, age=99) +# self.session.add_all([person1, person2]) +# self.session.commit() + +# # Updates a different field on each person. +# data = dict(person=[dict(id=1, name='bar'), dict(id=2, age=10)]) +# response = self.app.put('/api/person/1,2', data=dumps(data)) +# assert response.status_code == 204 +# assert person1.name == 'bar' +# assert person2.age == 10 + +# def test_put_multiple_without_id(self): +# person1 = self.Person(id=1, name='foo') +# person2 = self.Person(id=2, age=99) +# self.session.add_all([person1, person2]) +# self.session.commit() + +# # In order to avoid ambiguity, attempts to update multiple instances +# # without specifying the ID in each object results in an error. +# data = dict(person=[dict(name='bar'), dict(id=2, age=10)]) +# response = self.app.put('/api/person/1,2', data=dumps(data)) +# assert response.status_code == 400 +# # TODO Check the error message, description, etc. + +# def test_put_to_one_nonexistent(self): +# person = self.Person(id=1) +# computer = self.Computer(id=1) +# self.session.add_all([person, computer]) +# self.session.commit() + +# # Set the owner of the computer to be the person with ID 1. +# data = dict(person='1') +# response = self.app.put('/api/computer/1/links/owner', +# data=dumps(data)) +# assert response.status_code == 204 +# assert computer.owner is person + +# def test_post_to_one(self): +# person = self.Person(id=1) +# computer = self.Computer(id=1) +# self.session.add_all([person, computer]) +# self.session.commit() + +# # Posting to a relationship URL should work if no link exists yet. +# data = dict(person='1') +# response = self.app.post('/api/computer/1/links/owner', +# data=dumps(data)) +# assert response.status_code == 204 + +# def test_post_to_one_exists(self): +# person1 = self.Person(id=1) +# person2 = self.Person(id=2) +# computer = self.Computer(id=1) +# person1.computers = [computer] +# self.session.add_all([person1, person2, computer]) +# self.session.commit() + +# # Posting to a relationship URL should fail if a link already exists. +# data = dict(person='1') +# response = self.app.post('/api/computer/1/links/owner', +# data=dumps(data)) +# assert response.status_code == 400 +# # TODO check the error message and description here. + +# def test_delete_to_one(self): +# person = self.Person(id=1) +# computer = self.Computer(id=1) +# person.computers = [computer] +# self.session.add_all([person, computer]) +# self.session.commit() + +# # Delete a relationship (without deleting the linked resource itself). +# response = self.app.delete('/api/computer/1/links/owner') +# assert response.status_code == 204 + +# def test_delete_to_one_nonexistent(self): +# computer = self.Computer(id=1) +# self.session.add(computer) +# self.session.commit() + +# # Attempting to delete a relationship that doesn't exist should fail. +# response = self.app.delete('/api/computer/1/links/owner') +# assert response.status_code == 400 +# # TODO check the error message and description here. + +# def test_post_to_many_relationship_url(self): +# person = self.Person(id=1) +# computer1 = self.Computer(id=1) +# computer2 = self.Computer(id=2) +# self.session.add_all([person, computer1, computer2]) +# self.session.commit() + +# # Add to the one-to-many relationship `computers` on a person instance. +# data = dict(computers='1') +# response = self.app.post('/api/person/1/links/computers', +# data=dumps(data)) +# assert response.status_code == 204 +# assert computer1 in person.computers +# assert computer2 not in person.computers + +# def test_post_to_many_relationship_url_multiple(self): +# person = self.Person(id=1) +# computer1 = self.Computer(id=1) +# computer2 = self.Computer(id=2) +# self.session.add_all([person, computer1, computer2]) +# self.session.commit() + +# # Add to the one-to-many relationship `computers` on a person instance. +# data = dict(computers=['1', '2']) +# response = self.app.post('/api/person/1/links/computers', +# data=dumps(data)) +# assert response.status_code == 204 +# assert computer1 in person.computers +# assert computer2 in person.computers + +# def test_post_already_exists(self): +# person = self.Person(id=1) +# self.session.add(person) +# self.session.commit() + +# # Attempts to create a person that already exist return an error. +# data = dict(person=dict(id=1)) +# response = self.app.post('/api/person', data=dumps(data)) +# assert response.status_code == 409 # Conflict + +# def test_delete_to_many(self): +# person = self.Person(id=1) +# computer1 = self.Computer(id=1) +# computer2 = self.Computer(id=2) +# person.computers = [computer1, computer2] +# self.session.add_all([person, computer1, computer2]) +# self.session.commit() + +# # Delete from the the one-to-many relationship `computers` on a person +# # instance. +# response = self.app.delete('/api/person/1/links/computers/1') +# assert response.status_code == 204 +# assert person.computers == [computer2] + +# def test_delete_to_many_multiple(self): +# person = self.Person(id=1) +# computer1 = self.Computer(id=1) +# computer2 = self.Computer(id=2) +# person.computers = [computer1, computer2] +# self.session.add_all([person, computer1, computer2]) +# self.session.commit() + +# # Add to the one-to-many relationship `computers` on a person instance. +# response = self.app.delete('/api/person/1/links/computers/1,2') +# assert response.status_code == 204 +# assert person.computers == [] + +# def test_put_nonexistent(self): +# data = dict(name='bar') +# response = self.app.put('/api/foo', data=dumps(data)) +# assert response.status_code == 404 + +# def test_post_nonexistent_relationship(self): +# data = dict(name='bar') +# response = self.app.post('/api/person/1/links/foo', data=dumps(data)) +# assert response.status_code == 404 + +# def test_delete_nonexistent_relationship(self): +# response = self.app.delete('/api/person/1/links/foo') +# assert response.status_code == 404 + +# def test_delete_multiple(self): +# person1 = self.Person(id=1) +# person2 = self.Person(id=2) +# self.session.add_all([person1, person2]) +# self.session.commit() + +# # Delete the person instances with IDs 1 and 2. +# response = self.app.delete('/api/person/1,2') +# assert response.status_code == 204 +# assert self.session.query(self.Person).count() == 0 + +# def test_errors(self): +# # TODO Test that errors are returned as described in JSON API docs. +# pass diff --git a/tests/test_updating.py b/tests/test_updating.py new file mode 100644 index 00000000..c58c09dc --- /dev/null +++ b/tests/test_updating.py @@ -0,0 +1,960 @@ +""" + tests.test_updating + ~~~~~~~~~~~~~~~~~~~ + + Provides tests for updating resources from endpoints generated by + Flask-Restless. + + This module includes tests for additional functionality that is not already + tested by :mod:`test_jsonapi`, the module that guarantees Flask-Restless + meets the minimum requirements of the JSON API specification. + + :copyright: 2015 Jeffrey Finkelstein and + contributors. + :license: GNU AGPLv3+ or BSD + +""" +from __future__ import division + +from datetime import datetime +from datetime import time +from json import JSONEncoder + +from sqlalchemy import Column +from sqlalchemy import Date +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Time +from sqlalchemy import Unicode +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import backref +from sqlalchemy.orm import relationship + +from flask.ext.restless import CONTENT_TYPE + +from .helpers import dumps +from .helpers import loads +from .helpers import MSIE8_UA +from .helpers import MSIE9_UA +from .helpers import ManagerTestBase + + +class TestUpdating(ManagerTestBase): + """Tests for updating resources.""" + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask_restless.manager.APIManager` for that application, and + creates the ReSTful API endpoints for the :class:`TestSupport.Person` + and :class:`TestSupport.Article` models. + + """ + super(TestUpdating, self).setUp() + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + name = Column(Unicode, unique=True) + bedtime = Column(Time) + date_created = Column(Date) + birth_datetime = Column(DateTime) + + # This example comes from the SQLAlchemy documentation. + # + # The SQLAlchemy documentation is licensed under the MIT license. + class Interval(self.Base): + __tablename__ = 'interval' + id = Column(Integer, primary_key=True) + start = Column(Integer, nullable=False) + end = Column(Integer, nullable=False) + + @hybrid_property + def length(self): + return self.end - self.start + + @length.setter + def length(self, value): + self.end = self.start + value + + @hybrid_property + def radius(self): + return self.length / 2 + + @radius.expression + def radius(cls): + return cls.length / 2 + + self.Person = Person + self.Interval = Interval + self.Base.metadata.create_all() + self.manager.create_api(Interval, methods=['PUT']) + self.manager.create_api(Person, methods=['PUT']) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_deserializing_time(self): + """Test for deserializing a JSON representation of a time field.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + # datetime.time objects are not serializable by default so we need to + # create a custom JSON encoder class. + class TimeEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, time): + return o.isoformat() + return super(self, JSONEncoder).default(o) + bedtime = datetime.now().time() + data = dict(data=dict(type='person', id='1', bedtime=bedtime)) + response = self.app.put('/api/person/1', data=dumps(data, + cls=TimeEncoder)) + assert response.status_code == 204 + assert person.bedtime == bedtime + + def test_deserializing_date(self): + """Test for deserializing a JSON representation of a date field.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + date_created = datetime.now().date() + data = dict(data=dict(type='person', id='1', + date_created=date_created)) + response = self.app.put('/api/person/1', data=dumps(data)) + assert response.status_code == 204 + assert person.date_created == date_created + + def test_deserializing_datetime(self): + """Test for deserializing a JSON representation of a date field.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + birth_datetime = datetime.now() + data = dict(data=dict(type='person', id='1', + birth_datetime=birth_datetime)) + response = self.app.put('/api/person/1', data=dumps(data)) + assert response.status_code == 204 + # When we did `dumps(data)` above, we lost the millisecond information, + # so we expect the updated person to not have that extra information. + birth_datetime = birth_datetime.replace(microsecond=0) + assert person.birth_datetime == birth_datetime + + def test_correct_content_type(self): + """Tests that the server responds with :http:status:`201` if the + request has the correct JSON API content type. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', id='1')) + response = self.app.put('/api/person/1', data=dumps(data), + content_type=CONTENT_TYPE) + assert response.status_code == 204 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_no_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has no content type. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', id=1)) + response = self.app.put('/api/person/1', data=dumps(data), + content_type=None) + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_wrong_content_type(self): + """Tests that the server responds with :http:status:`415` if the + request has the wrong content type. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', id=1)) + bad_content_types = ('application/json', 'application/javascript') + for content_type in bad_content_types: + response = self.app.put('/api/person/1', data=dumps(data), + content_type=content_type) + assert response.status_code == 415 + assert response.headers['Content-Type'] == CONTENT_TYPE + + def test_msie8(self): + """Tests for compatibility with Microsoft Internet Explorer 8. + + According to issue #267, making requests using JavaScript from MSIE8 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + headers = {'User-Agent': MSIE8_UA} + content_type = 'text/html' + data = dict(data=dict(type='person', id='1')) + response = self.app.put('/api/person/1', data=dumps(data), + headers=headers, content_type=content_type) + assert response.status_code == 204 + + def test_msie9(self): + """Tests for compatibility with Microsoft Internet Explorer 9. + + According to issue #267, making requests using JavaScript from MSIE9 + does not allow changing the content type of the request (it is always + ``text/html``). Therefore Flask-Restless should ignore the content type + when a request is coming from this client. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + headers = {'User-Agent': MSIE9_UA} + content_type = 'text/html' + data = dict(data=dict(type='person', id='1')) + response = self.app.put('/api/person/1', data=dumps(data), + headers=headers, content_type=content_type) + assert response.status_code == 204 + + def test_rollback_on_integrity_error(self): + """Tests that an integrity error in the database causes a session + rollback, and that the server can still process requests correctly + after this rollback. + + """ + person1 = self.Person(id=1, name='foo') + person2 = self.Person(id=2, name='bar') + self.session.add_all([person1, person2]) + self.session.commit() + data = dict(data=dict(type='person', id='2', name='foo')) + response = self.app.put('/api/person/2', data=dumps(data)) + assert response.status_code == 409 # Conflict + assert self.session.is_active, 'Session is in `partial rollback` state' + person = dict(data=dict(type='person', id='2', name='baz')) + response = self.app.put('/api/person/2', data=dumps(person)) + assert response.status_code == 204 + assert person2.name == 'baz' + + def test_empty_request(self): + """Test for making a :http:method:`put` request with no data.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.put('/api/person/1') + assert response.status_code == 400 + # TODO check the error message here + + def test_empty_string(self): + """Test for making a :http:method:`put` request with an empty string, + which is invalid JSON. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.put('/api/person/1', data='') + assert response.status_code == 400 + # TODO check the error message here + + def test_invalid_json(self): + """Tests that a request with invalid JSON yields an error response.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + response = self.app.put('/api/person/1', data='Invalid JSON string') + assert response.status_code == 400 + # TODO check error message here + + def test_nonexistent_attribute(self): + """Tests that attempting to make a :http:method:`put` request on an + attribute that does not exist on the specified model yields an error + response. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = dict(data=dict(type='person', id='1', bogus=0)) + response = self.app.put('/api/person/1', data=dumps(data)) + assert 400 == response.status_code + + def test_read_only_hybrid_property(self): + """Tests that an attempt to set a read-only hybrid property causes an + error. + + For more information, see issue #171. + + """ + interval = self.Interval(id=1, start=5, end=10) + self.session.add(interval) + self.session.commit() + data = dict(data=dict(type='interval', id='1', radius=1)) + response = self.app.put('/api/interval/1', data=dumps(data)) + assert response.status_code == 400 + # TODO check error message here + + def test_set_hybrid_property(self): + """Tests that a hybrid property can be correctly set by a client.""" + interval = self.Interval(id=1, start=5, end=10) + self.session.add(interval) + self.session.commit() + data = dict(data=dict(type='interval', id='1', length=4)) + response = self.app.put('/api/interval/1', data=dumps(data)) + assert interval.start == 5 + assert interval.end == 9 + assert interval.radius == 2 + + # def test_content_type(self): + # """Tests that the server responds only to requests with a JSON + # Content-Type. + + # """ + # # Same goes for a PATCH request. + # response = self.app.patch('/api/person/6', data=dumps(dict(name='x')), + # content_type=None) + # assert 415 == response.status_code + # response = self.app.patch('/api/person/6', data=dumps(dict(name='x')), + # content_type='application/vnd.api+json') + # assert 200 == response.status_code + # content_type = 'application/vnd.api+json; charset=UTF-8' + # response = self.app.patch('/api/person/6', data=dumps(dict(name='x')), + # content_type=content_type) + # assert 200 == response.status_code + + # # A request without an Accept header should return JSON. + # assert 'Content-Type' in response.headers + # assert 'application/vnd.api+json' == response.headers['Content-Type'] + # assert 'x' == loads(response.data)['name'] + + # TODO This is not required by JSON API, and it was a little bit flimsy + # anyway. + # + # def test_patch_update_relations(self): + # """Test for posting a new model and simultaneously adding related + # instances *and* updating information on those instances. + + # For more information see issue #164. + + # """ + # # First, create a new computer object with an empty `name` field and a + # # new person with no related computers. + # response = self.app.post('/api/computer', data=dumps({})) + # assert 201 == response.status_code + # response = self.app.post('/api/person', data=dumps({})) + # assert 201 == response.status_code + # # Second, patch the person by setting its list of related computer + # # instances to include the previously created computer, *and* + # # simultaneously update the `name` attribute of that computer. + # data = dict(computers=[dict(id=1, name='foo')]) + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert 200 == response.status_code + # # Check that the computer now has its `name` field set. + # response = self.app.get('/api/computer/1') + # assert 200 == response.status_code + # assert 'foo' == loads(response.data)['name'] + # # Add a new computer by patching person + # data = {'computers': [{'id': 1}, + # {'name': 'iMac', 'vendor': 'Apple', + # 'programs': [{'program': {'name': 'iPhoto'}}]}]} + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert 200 == response.status_code + # response = self.app.get('/api/computer/2/programs') + # programs = loads(response.data)['objects'] + # assert programs[0]['program']['name'] == 'iPhoto' + # # Add a program to the computer through the person + # data = {'computers': [{'id': 1}, + # {'id': 2, + # 'programs': [{'program_id': 1}, + # {'program': {'name': 'iMovie'}}]}]} + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert 200 == response.status_code + # response = self.app.get('/api/computer/2/programs') + # programs = loads(response.data)['objects'] + # assert programs[1]['program']['name'] == 'iMovie' + + # TODO this is not required by the JSON API spec. + # + # def test_disallow_patch_many(self): + # """Tests that disallowing "patch many" requests responds with a + # :http:statuscode:`405`. + + # """ + # response = self.app.patch('/api/person', data=dumps(dict(name='foo'))) + # assert response.status_code == 405 + + # TODO this is not required by the JSON API spec. + # + # def test_patch_many(self): + # """Test for updating a collection of instances of the model using the + # :http:method:`patch` method. + + # """ + # # recreate the api to allow patch many at /api/v2/person + # self.manager.create_api(self.Person, methods=['GET', 'POST', 'PATCH'], + # allow_patch_many=True, url_prefix='/api/v2') + + # # Creating some people + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Lincoln', 'age': 23})) + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Lucy', 'age': 23})) + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Mary', 'age': 25})) + + # # Trying to pass invalid data to the update method + # resp = self.app.patch('/api/v2/person', data='Hello there') + # assert resp.status_code == 400 + # assert loads(resp.data)['message'] == 'Unable to decode data' + + # # Changing the birth date field of the entire collection + # day, month, year = 15, 9, 1986 + # birth_date = date(year, month, day).strftime('%d/%m/%Y') # iso8601 + # form = {'birth_date': birth_date} + # self.app.patch('/api/v2/person', data=dumps(form)) + + # # Finally, testing if the change was made + # response = self.app.get('/api/v2/person') + # loaded = loads(response.data)['objects'] + # for i in loaded: + # expected = '{0:4d}-{1:02d}-{2:02d}'.format(year, month, day) + # assert i['birth_date'] == expected + + # TODO this is not required by the JSON API spec. + # + # def test_patch_many_with_filter(self): + # """Test for updating a collection of instances of the model using a + # :http:method:patch request with filters. + + # """ + # # recreate the api to allow patch many at /api/v2/person + # self.manager.create_api(self.Person, methods=['GET', 'POST', 'PATCH'], + # allow_patch_many=True, url_prefix='/api/v2') + # # Creating some people + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Lincoln', 'age': 23})) + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Lucy', 'age': 23})) + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Mary', 'age': 25})) + # search = {'filters': [{'name': 'name', 'val': u'Lincoln', + # 'op': 'equals'}]} + # # Changing the birth date field for objects where name field equals + # # Lincoln + # day, month, year = 15, 9, 1986 + # birth_date = date(year, month, day).strftime('%d/%m/%Y') # iso8601 + # form = {'birth_date': birth_date, 'q': search} + # response = self.app.patch('/api/v2/person', data=dumps(form)) + # num_modified = loads(response.data)['num_modified'] + # assert num_modified == 1 + + # TODO this is not required by the JSON API spec. + # + # def test_put_same_as_patch(self): + # """Tests that :http:method:`put` requests are the same as + # :http:method:`patch` requests. + + # """ + # # recreate the api to allow patch many at /api/v2/person + # self.manager.create_api(self.Person, methods=['GET', 'POST', 'PUT'], + # allow_patch_many=True, url_prefix='/api/v2') + + # # Creating some people + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Lincoln', 'age': 23})) + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Lucy', 'age': 23})) + # self.app.post('/api/v2/person', + # data=dumps({'name': u'Mary', 'age': 25})) + + # # change a single entry + # resp = self.app.put('/api/v2/person/1', data=dumps({'age': 24})) + # assert resp.status_code == 200 + + # resp = self.app.get('/api/v2/person/1') + # assert resp.status_code == 200 + # assert loads(resp.data)['age'] == 24 + + # # Changing the birth date field of the entire collection + # day, month, year = 15, 9, 1986 + # birth_date = date(year, month, day).strftime('%d/%m/%Y') # iso8601 + # form = {'birth_date': birth_date} + # self.app.put('/api/v2/person', data=dumps(form)) + + # # Finally, testing if the change was made + # response = self.app.get('/api/v2/person') + # loaded = loads(response.data)['objects'] + # for i in loaded: + # expected = '{0:4d}-{1:02d}-{2:02d}'.format(year, month, day) + # assert i['birth_date'] == expected + + # def test_set_hybrid_property(self): + # self.manager.create_api(HybridPerson, methods=['POST', 'PATCH'], + # collection_name='hybrid') + # response = self.app.post('/api/hybrid', data=dumps({'abs_other': 1})) + # assert 201 == response.status_code + # data = loads(response.data) + # assert 1 == data['other'] + # assert 1 == data['abs_other'] + + # response = self.app.post('/api/hybrid', data=dumps({'name': u'Rod'})) + # assert 201 == response.status_code + # response = self.app.patch('/api/hybrid/2', data=dumps({'sq_other': 4})) + # assert 200 == response.status_code + # data = loads(response.data) + # assert 2 == data['other'] + # assert 4 == data['sq_other'] + + # def test_patch_with_hybrid_property(self): + # """Tests that a hybrid property can be correctly posted from a client. + + # """ + # self.session.add(self.Screen(id=1, width=5, height=4)) + # self.session.commit() + # self.manager.create_api(self.Screen, methods=['PATCH'], + # collection_name='screen') + # response = self.app.patch('/api/screen/1', + # data=dumps({"number_of_pixels": 50})) + # assert 200 == response.status_code + # data = loads(response.data) + # assert 5 == data['width'] + # assert 10 == data['height'] + # assert 50 == data['number_of_pixels'] + + # TODO no longer supported? + # + # def test_patch_set_submodel(self): + # """Test for assigning a list to a relation of a model using + # :http:method:`patch`. + + # """ + # # create the person + # response = self.app.post('/api/person', data=dumps({})) + # assert response.status_code == 201 + + # # patch the person with some computers + # data = {'computers': [{'name': u'lixeiro', 'vendor': u'Lemote'}, + # {'name': u'foo', 'vendor': u'bar'}]} + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert 200 == response.status_code + # data = loads(response.data) + # assert 2 == len(data['computers']) + # assert u'lixeiro' == data['computers'][0]['name'] + # assert u'Lemote' == data['computers'][0]['vendor'] + # assert u'foo' == data['computers'][1]['name'] + # assert u'bar' == data['computers'][1]['vendor'] + + # # change one of the computers + # data = {'computers': [{'id': data['computers'][0]['id']}, + # {'id': data['computers'][1]['id'], + # 'vendor': u'Apple'}]} + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert 200 == response.status_code + # data = loads(response.data) + # assert 2 == len(data['computers']) + # assert u'lixeiro' == data['computers'][0]['name'] + # assert u'Lemote' == data['computers'][0]['vendor'] + # assert u'foo' == data['computers'][1]['name'] + # assert u'Apple' == data['computers'][1]['vendor'] + + # # patch the person with some new computers + # data = {'computers': [{'name': u'hey', 'vendor': u'you'}, + # {'name': u'big', 'vendor': u'money'}, + # {'name': u'milk', 'vendor': u'chocolate'}]} + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert 200 == response.status_code + # data = loads(response.data) + # assert 3 == len(data['computers']) + # assert u'hey' == data['computers'][0]['name'] + # assert u'big' == data['computers'][1]['name'] + # assert u'milk' == data['computers'][2]['name'] + + # TODO Already tested in test_jsonapi + # + # def test_patch_duplicate(self): + # """Test for assigning a list containing duplicate items to a relation + # of a model using :http:method:`patch`. + + # """ + # # create the manufacturer with a duplicate car + # data = {'name': u'Ford', 'models': [{'name': u'Maverick', 'seats': 2}, + # {'name': u'Mustang', 'seats': 4}, + # {'name': u'Maverick', 'seats': 2}]} + # response = self.app.post('/api/car_manufacturer', data=dumps(data)) + # assert response.status_code == 201 + # responsedata = loads(response.data) + # assert 3 == len(data['models']) + # assert u'Maverick' == responsedata['models'][0]['name'] + # assert u'Mustang' == responsedata['models'][1]['name'] + # assert u'Maverick' == responsedata['models'][2]['name'] + + # # add another duplicate car + # data['models'].append({'name': u'Mustang', 'seats': 4}) + # response = self.app.patch('/api/car_manufacturer/1', data=dumps(data)) + # assert response.status_code == 200 + # data = loads(response.data) + # assert 4 == len(data['models']) + # assert u'Maverick' == data['models'][0]['name'] + # assert u'Mustang' == data['models'][1]['name'] + # assert u'Maverick' == data['models'][2]['name'] + # assert u'Mustang' == data['models'][3]['name'] + + # TODO no longer supported + # + # def test_patch_new_single(self): + # """Test for adding a single new object to a one-to-one relationship + # using :http:method:`patch`. + + # """ + # # create the person + # data = {'name': u'Lincoln', 'age': 23} + # response = self.app.post('/api/person', data=dumps(data)) + # assert response.status_code == 201 + + # # patch the person with a new computer + # data = {'computers': {'add': {'name': u'lixeiro', + # 'vendor': u'Lemote'}}} + + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert response.status_code == 200 + + # # Let's check it out + # response = self.app.get('/api/person/1') + # loaded = loads(response.data) + + # assert len(loaded['computers']) == 1 + # assert loaded['computers'][0]['name'] == \ + # data['computers']['add']['name'] + # assert loaded['computers'][0]['vendor'] == \ + # data['computers']['add']['vendor'] + + # # test that this new computer was added to the database as well + # computer = self.session.query(self.Computer).filter_by(id=1).first() + # assert computer is not None + # assert data['computers']['add']['name'] == computer.name + # assert data['computers']['add']['vendor'] == computer.vendor + + # TODO no longer supported + # + # def test_patch_existing_single(self): + # """Test for adding a single existing object to a one-to-one + # relationship using :http:method:`patch`. + + # """ + # # create the person + # data = {'name': u'Lincoln', 'age': 23} + # response = self.app.post('/api/person', data=dumps(data)) + # assert response.status_code == 201 + + # # create the computer + # data = {'name': u'lixeiro', 'vendor': u'Lemote'} + # response = self.app.post('/api/computer', data=dumps(data)) + # assert response.status_code == 201 + + # # patch the person with the created computer + # data = {'computers': {'add': {'id': 1}}} + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert response.status_code == 200 + + # # Let's check it out + # response = self.app.get('/api/person/1') + # loaded = loads(response.data) + + # assert len(loaded['computers']) == 1 + # assert loaded['computers'][0]['id'] == data['computers']['add']['id'] + + # def test_patch_add_submodels(self): + # """Test for updating a single instance of the model by adding a list of + # related models using the :http:method:`patch` method. + + # """ + # data = dict(name=u'Lincoln', age=23) + # response = self.app.post('/api/person', data=dumps(data)) + # assert response.status_code == 201 + + # add1 = {'name': u'lixeiro', 'vendor': u'Lemote'} + # add2 = {'name': u'foo', 'vendor': u'bar'} + # data = {'computers': {'add': [add1, add2]}} + # response = self.app.patch('/api/person/1', data=dumps(data)) + # assert response.status_code == 200 + # response = self.app.get('/api/person/1') + # loaded = loads(response.data) + + # assert len(loaded['computers']) == 2 + # assert loaded['computers'][0]['name'] == u'lixeiro' + # assert loaded['computers'][0]['vendor'] == u'Lemote' + # assert loaded['computers'][1]['name'] == u'foo' + # assert loaded['computers'][1]['vendor'] == u'bar' + + # # test that these new computers were added to the database as well + # computer = self.session.query(self.Computer).filter_by(id=1).first() + # assert computer is not None + # assert u'lixeiro' == computer.name + # assert u'Lemote' == computer.vendor + # computer = self.session.query(self.Computer).filter_by(id=2).first() + # assert computer is not None + # assert u'foo' == computer.name + # assert u'bar' == computer.vendor + + # def test_patch_remove_submodel(self): + # """Test for updating a single instance of the model by removing a + # related model using the :http:method:`patch` method. + + # """ + # # Creating the row that will be updated + # data = { + # 'name': u'Lincoln', 'age': 23, + # 'computers': [ + # {'name': u'lixeiro', 'vendor': u'Lemote'}, + # {'name': u'pidinti', 'vendor': u'HP'}, + # ], + # } + # self.app.post('/api/person', data=dumps(data)) + + # # Data for the update + # update_data = { + # 'computers': { + # 'remove': [{'name': u'pidinti'}], + # } + # } + # resp = self.app.patch('/api/person/1', data=dumps(update_data)) + # assert resp.status_code == 200 + # assert loads(resp.data)['id'] == 1 + + # # Let's check it out + # response = self.app.get('/api/person/1') + # loaded = loads(response.data) + # assert len(loaded['computers']) == 1 + + # TODO no longer supported + # + # def test_patch_autodelete_submodel(self): + # """Tests the automatic deletion of entries marked with the + # ``__delete__`` flag on an update operation. + + # It also tests adding an already created instance as a related item. + + # """ + # # Creating all rows needed in our test + # person_data = {'name': u'Lincoln', 'age': 23} + # resp = self.app.post('/api/person', data=dumps(person_data)) + # assert resp.status_code == 201 + # comp_data = {'name': u'lixeiro', 'vendor': u'Lemote'} + # resp = self.app.post('/api/computer', data=dumps(comp_data)) + # assert resp.status_code == 201 + + # # updating person to add the computer + # update_data = {'computers': {'add': [{'id': 1}]}} + # self.app.patch('/api/person/1', data=dumps(update_data)) + + # # Making sure that everything worked properly + # resp = self.app.get('/api/person/1') + # assert resp.status_code == 200 + # loaded = loads(resp.data) + # assert len(loaded['computers']) == 1 + # assert loaded['computers'][0]['name'] == u'lixeiro' + + # # Now, let's remove it and delete it + # update2_data = { + # 'computers': { + # 'remove': [ + # {'id': 1, '__delete__': True}, + # ], + # }, + # } + # resp = self.app.patch('/api/person/1', data=dumps(update2_data)) + # assert resp.status_code == 200 + + # # Testing to make sure it was removed from the related field + # resp = self.app.get('/api/person/1') + # assert resp.status_code == 200 + # loaded = loads(resp.data) + # assert len(loaded['computers']) == 0 + + # # Making sure it was removed from the database + # resp = self.app.get('/api/computer/1') + # assert resp.status_code == 404 + + +class TestAssociationProxy(ManagerTestBase): + """Tests for creating an object with a relationship using an association + proxy. + + """ + + def setUp(self): + """Creates the database, the :class:`~flask.Flask` object, the + :class:`~flask.ext.restless.manager.APIManager` for that application, + and creates the ReSTful API endpoints for the models used in the test + methods. + + """ + super(TestAssociationProxy, self).setUp() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + tags = association_proxy('articletags', 'tag', + creator=lambda tag: ArticleTag(tag=tag)) + + class ArticleTag(self.Base): + __tablename__ = 'articletag' + article_id = Column(Integer, ForeignKey('article.id'), + primary_key=True) + article = relationship(Article, backref=backref('articletags')) + tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True) + tag = relationship('Tag') + # extra_info = Column(Unicode) + + class Tag(self.Base): + __tablename__ = 'tag' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + + self.Article = Article + self.Tag = Tag + self.Base.metadata.create_all() + self.manager.create_api(Article, methods=['PUT']) + # HACK Need to create APIs for these other models because otherwise + # we're not able to create the link URLs to them. + # + # TODO Fix this by simply not creating links to related models for + # which no API has been made. + self.manager.create_api(Tag) + self.manager.create_api(ArticleTag) + + def tearDown(self): + """Drops all tables from the temporary database.""" + self.Base.metadata.drop_all() + + def test_update(self): + """Test for updating a model with a many-to-many relation that uses an + association object to allow extra data to be stored in the association + table. + + For more info, see issue #166. + + """ + article = self.Article(id=1) + tag1 = self.Tag(id=1) + tag2 = self.Tag(id=2) + self.session.add_all([article, tag1, tag2]) + self.session.commit() + self.manager.create_api(self.Article, methods=['PUT'], + url_prefix='/api2', + allow_to_many_replacement=True) + data = {'data': + {'type': 'article', + 'id': '1', + 'links': + {'tags': + {'type': 'tag', 'ids': ['1', '2']} + } + } + } + response = self.app.put('/api2/article/1', data=dumps(data)) + assert response.status_code == 204 + assert set(article.tags) == {tag1, tag2} + + # data = { + # 'programs': { + # 'add': [ + # { + # 'program_id': 1, + # 'licensed': False + # } + # ] + # } + # } + # response = self.app.patch('/api/computer/1', data=dumps(data)) + # computer = loads(response.data) + # assert 200 == response.status_code + # vim_relation = { + # 'computer_id': 1, + # 'program_id': 1, + # 'licensed': False + # } + # assert vim_relation in computer['programs'] + # data = { + # 'programs': { + # 'add': [ + # { + # 'program_id': 2, + # 'licensed': True + # } + # ] + # } + # } + # response = self.app.patch('/api/computer/1', data=dumps(data)) + # computer = loads(response.data) + # assert 200 == response.status_code + # emacs_relation = { + # 'computer_id': 1, + # 'program_id': 2, + # 'licensed': True + # } + # assert emacs_relation in computer['programs'] + # vim_relation = { + # 'computer_id': 1, + # 'program_id': 1, + # 'licensed': False + # } + # assert vim_relation in computer['programs'] + + # def test_patch_remove_m2m(self): + # """Test for removing a relation on a model that uses an association + # object to allow extra data to be stored in the helper table. + + # For more information, see issue #166. + + # """ + # response = self.app.post('/api/computer', data=dumps({})) + # assert 201 == response.status_code + # vim = self.Program(name=u'Vim') + # emacs = self.Program(name=u'Emacs') + # self.session.add_all([vim, emacs]) + # self.session.commit() + # data = { + # 'programs': [ + # { + # 'program_id': 1, + # 'licensed': False + # }, + # { + # 'program_id': 2, + # 'licensed': True + # } + # ] + # } + # response = self.app.patch('/api/computer/1', data=dumps(data)) + # computer = loads(response.data) + # assert 200 == response.status_code + # vim_relation = { + # 'computer_id': 1, + # 'program_id': 1, + # 'licensed': False + # } + # emacs_relation = { + # 'computer_id': 1, + # 'program_id': 2, + # 'licensed': True + # } + # assert vim_relation in computer['programs'] + # assert emacs_relation in computer['programs'] + # data = { + # 'programs': { + # 'remove': [{'program_id': 1}] + # } + # } + # response = self.app.patch('/api/computer/1', data=dumps(data)) + # computer = loads(response.data) + # assert 200 == response.status_code + # assert vim_relation not in computer['programs'] + # assert emacs_relation in computer['programs'] diff --git a/tests/test_views.py b/tests/test_views.py index a7ba5af6..aa041714 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -64,231 +64,100 @@ MSIE9_UA = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)' -@skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') -class TestFSAModel(FlaskTestBase): - """Tests for functions which operate on Flask-SQLAlchemy models.""" - - def setUp(self): - """Creates the Flask-SQLAlchemy database and models.""" - super(TestFSAModel, self).setUp() - - db = SQLAlchemy(self.flaskapp) - - class User(db.Model): - id = db.Column(db.Integer, primary_key=True) - - class Pet(db.Model): - id = db.Column(db.Integer, primary_key=True) - ownerid = db.Column(db.Integer, db.ForeignKey(User.id)) - owner = db.relationship(User, backref=db.backref('pets')) - - class LazyUser(db.Model): - id = db.Column(db.Integer, primary_key=True) - - class LazyPet(db.Model): - id = db.Column(db.Integer, primary_key=True) - ownerid = db.Column(db.Integer, db.ForeignKey(LazyUser.id)) - owner = db.relationship(LazyUser, - backref=db.backref('pets', lazy='dynamic')) - - self.User = User - self.Pet = Pet - self.LazyUser = LazyUser - self.LazyPet = LazyPet - - self.db = db - self.db.create_all() - - self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db) - - def tearDown(self): - """Drops all tables.""" - self.db.drop_all() - unregister_fsa_session_signals() - - def test_get(self): - """Test for the :meth:`views.API.get` method with models defined using - Flask-SQLAlchemy with both dynamically loaded and static relationships. - - """ - # create the API endpoint - self.manager.create_api(self.User) - self.manager.create_api(self.LazyUser) - self.manager.create_api(self.Pet) - self.manager.create_api(self.LazyPet) - - response = self.app.get('/api/user') - assert 200 == response.status_code - response = self.app.get('/api/lazy_user') - assert 200 == response.status_code - response = self.app.get('/api/pet') - assert 200 == response.status_code - response = self.app.get('/api/lazy_pet') - assert 200 == response.status_code - - # create a user with two pets - owner = self.User() - pet1 = self.Pet() - pet2 = self.Pet() - pet3 = self.Pet() - pet1.owner = owner - pet2.owner = owner - self.db.session.add_all([owner, pet1, pet2, pet3]) - self.db.session.commit() - - response = self.app.get('/api/user/{0:d}'.format(owner.id)) - assert 200 == response.status_code - data = loads(response.data) - assert 2 == len(data['pets']) - for pet in data['pets']: - assert owner.id == pet['ownerid'] - - response = self.app.get('/api/pet/1') - assert 200 == response.status_code - data = loads(response.data) - assert not isinstance(data['owner'], list) - assert owner.id == data['ownerid'] - - # create a lazy user with two lazy pets - owner = self.LazyUser() - pet1 = self.LazyPet() - pet2 = self.LazyPet() - pet1.owner = owner - pet2.owner = owner - self.db.session.add_all([owner, pet1, pet2]) - self.db.session.commit() - - response = self.app.get('/api/lazy_user/{0:d}'.format(owner.id)) - assert 200 == response.status_code - data = loads(response.data) - assert 2 == len(data['pets']) - for pet in data['pets']: - assert owner.id == pet['ownerid'] - - response = self.app.get('/api/lazy_pet/1') - assert 200 == response.status_code - data = loads(response.data) - assert not isinstance(data['owner'], list) - assert owner.id == data['ownerid'] - - # Check that it's possible to get owner if not null - response = self.app.get('/api/pet/1/owner') - assert 200 == response.status_code - data = loads(response.data) - assert 2 == len(data['pets']) - # And that we get a 404 if owner is null - response = self.app.get('/api/pet/3/owner') - assert 404 == response.status_code - - -class TestFunctionAPI(TestSupportPrefilled): - """Unit tests for the :class:`flask_restless.views.FunctionAPI` class.""" - - def setUp(self): - """Creates the database, the :class:`~flask.Flask` object, the - :class:`~flask_restless.manager.APIManager` for that application, and - creates the ReSTful API endpoints for the :class:`testapp.Person` and - :class:`testapp.Computer` models. - - """ - super(TestFunctionAPI, self).setUp() - self.manager.create_api(self.Person, allow_functions=True) - - def test_function_evaluation(self): - """Test that the :http:get:`/api/eval/person` endpoint returns the - result of evaluating functions. - - """ - functions = [{'name': 'sum', 'field': 'age'}, - {'name': 'avg', 'field': 'other'}, - {'name': 'count', 'field': 'id'}] - query = dumps(dict(functions=functions)) - response = self.app.get('/api/eval/person?q={0}'.format(query)) - assert response.status_code == 200 - data = loads(response.data) - assert 'sum__age' in data - assert data['sum__age'] == 102.0 - assert 'avg__other' in data - assert data['avg__other'] == 16.2 - assert 'count__id' in data - assert data['count__id'] == 5 - - def test_no_functions(self): - """Tests that if no functions are defined, an empty response is - returned. - - """ - # no data is invalid JSON - response = self.app.get('/api/eval/person') - assert response.status_code == 400 - # so is the empty string - response = self.app.get('/api/eval/person?q=') - assert response.status_code == 400 - - # if we provide no functions, then we expect an empty response - response = self.app.get('/api/eval/person?q={0}'.format(dumps(dict()))) - assert response.status_code == 204 - - def test_poorly_defined_functions(self): - """Tests that poorly defined requests for function evaluations cause an - error message to be returned. - - """ - # test for bad field name - search = {'functions': [{'name': 'sum', 'field': 'bogusfieldname'}]} - resp = self.app.get('/api/eval/person?q={0}'.format(dumps(search))) - assert resp.status_code == 400 - assert 'message' in loads(resp.data) - assert 'bogusfieldname' in loads(resp.data)['message'] - - # test for bad function name - search = {'functions': [{'name': 'bogusfuncname', 'field': 'age'}]} - resp = self.app.get('/api/eval/person?q={0}'.format(dumps(search))) - assert resp.status_code == 400 - assert 'message' in loads(resp.data) - assert 'bogusfuncname' in loads(resp.data)['message'] - - def test_jsonp(self): - """Test for JSON-P callbacks.""" - person1 = self.Person(age=10) - person2 = self.Person(age=20) - person3 = self.Person(age=35) - self.session.add_all([person1, person2, person3]) - self.session.commit() - functions = [{'name': 'sum', 'field': 'age'}] - query = dumps(dict(functions=functions)) - # JSONP should work on function evaluation endpoints as well as on - # normal GET endpoints. - response = self.app.get('/api/eval/person?' - 'q={0}&callback=baz'.format(query)) - assert response.status_code == 200 - assert response.mimetype == 'application/javascript' - assert response.data.startswith(b'baz(') - assert response.data.endswith(b')') - - # Add some more people so the result will be paginated. - for n in range(20): - self.session.add(self.Person(name=u'{0}'.format(n))) - self.session.commit() - response = self.app.get('/api/person?callback=baz') - assert response.status_code == 200 - assert response.data.startswith(b'baz(') - assert response.data.endswith(b')') - # Get the dictionary representation of the JSON string inside the - # 'baz()' part of the JSONP response. - data = loads(response.data[4:-1]) - assert 'meta' in data - assert 'data' in data - # The meta should include a JSON representation of the HTTP status. - assert 'status' in data['meta'] - assert data['meta']['status'] == 200 - # The metadata should include a JSON representation of the HTTP Link - # header information. - assert 'Link' in data['meta'] - assert len(data['meta']['Link']) == 2 - assert data['meta']['Link'][0]['rel'] == 'next' - assert data['meta']['Link'][1]['rel'] == 'last' - # TODO What other headers should the metadata include? +# @skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') +# class TestFSAModel(FlaskTestBase): +# """Tests for functions which operate on Flask-SQLAlchemy models.""" + +# def setUp(self): +# """Creates the Flask-SQLAlchemy database and models.""" +# super(TestFSAModel, self).setUp() +# self.db = SQLAlchemy(self.flaskapp) + +# class Person(self.db.Model): +# id = self.db.Column(self.db.Integer, primary_key=True) + +# self.Person = Person +# self.db.create_all() +# self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db) + +# def tearDown(self): +# """Drops all tables.""" +# self.db.drop_all() +# unregister_fsa_session_signals() + +# # def test_get(self): +# # """Test for the :meth:`views.API.get` method with models defined using +# # Flask-SQLAlchemy with both dynamically loaded and static relationships. + +# # """ +# # # create the API endpoint +# # self.manager.create_api(self.User) +# # self.manager.create_api(self.LazyUser) +# # self.manager.create_api(self.Pet) +# # self.manager.create_api(self.LazyPet) + +# # response = self.app.get('/api/user') +# # assert 200 == response.status_code +# # response = self.app.get('/api/lazy_user') +# # assert 200 == response.status_code +# # response = self.app.get('/api/pet') +# # assert 200 == response.status_code +# # response = self.app.get('/api/lazy_pet') +# # assert 200 == response.status_code + +# # # create a user with two pets +# # owner = self.User() +# # pet1 = self.Pet() +# # pet2 = self.Pet() +# # pet3 = self.Pet() +# # pet1.owner = owner +# # pet2.owner = owner +# # self.db.session.add_all([owner, pet1, pet2, pet3]) +# # self.db.session.commit() + +# # response = self.app.get('/api/user/{0:d}'.format(owner.id)) +# # assert 200 == response.status_code +# # data = loads(response.data) +# # assert 2 == len(data['pets']) +# # for pet in data['pets']: +# # assert owner.id == pet['ownerid'] + +# # response = self.app.get('/api/pet/1') +# # assert 200 == response.status_code +# # data = loads(response.data) +# # assert not isinstance(data['owner'], list) +# # assert owner.id == data['ownerid'] + +# # # create a lazy user with two lazy pets +# # owner = self.LazyUser() +# # pet1 = self.LazyPet() +# # pet2 = self.LazyPet() +# # pet1.owner = owner +# # pet2.owner = owner +# # self.db.session.add_all([owner, pet1, pet2]) +# # self.db.session.commit() + +# # response = self.app.get('/api/lazy_user/{0:d}'.format(owner.id)) +# # assert 200 == response.status_code +# # data = loads(response.data) +# # assert 2 == len(data['pets']) +# # for pet in data['pets']: +# # assert owner.id == pet['ownerid'] + +# # response = self.app.get('/api/lazy_pet/1') +# # assert 200 == response.status_code +# # data = loads(response.data) +# # assert not isinstance(data['owner'], list) +# # assert owner.id == data['ownerid'] + +# # # Check that it's possible to get owner if not null +# # response = self.app.get('/api/pet/1/owner') +# # assert 200 == response.status_code +# # data = loads(response.data) +# # assert 2 == len(data['pets']) +# # # And that we get a 404 if owner is null +# # response = self.app.get('/api/pet/3/owner') +# # assert 404 == response.status_code class TestAPI(TestSupport): @@ -322,213 +191,188 @@ def setUp(self): # to facilitate searching self.app.search = lambda url, q: self.app.get(url + '?q={0}'.format(q)) - def test_post(self): - """Test for creating a new instance of the database model using the - :http:method:`post` method. - - """ - # Invalid JSON in request data should respond with error. - response = self.app.post('/api/person', data='Invalid JSON string') - assert response.status_code == 400 - assert loads(response.data)['message'] == 'Unable to decode data' - - # Now, let's test the validation stuff - # response = self.app.post('/api/person', data=dumps({'name': u'Test', - # 'age': 'oi'})) - # assert loads(response.data)['message'] == 'Validation error' - # assert loads(response.data)['error_list'].keys() == ['age'] - - # Test the integrity exception by violating the unique 'name' field - # of person - response = self.app.post('/api/person', - data=dumps({'name': u'George', 'age': 23})) - assert response.status_code == 201 - - # This errors as expected - response = self.app.post('/api/person', - data=dumps({'name': u'George', 'age': 23})) - assert response.status_code == 400 - assert json.loads(response.data)['message'] == 'IntegrityError' - assert self.session.is_active, "Session is in `partial rollback` state" - - # For issue #158 we make sure that the previous failure is rolled back - # so that we can add valid entries again - response = self.app.post('/api/person', - data=dumps({'name': u'Benjamin', 'age': 23})) - assert response.status_code == 201 - - response = self.app.post('/api/person', - data=dumps({'name': u'Lincoln', 'age': 23})) - assert response.status_code == 201 - assert 'id' in loads(response.data) - - response = self.app.get('/api/person/1') - assert response.status_code == 200 - - deep = {'computers': [], 'projects': []} - person = self.session.query(self.Person).filter_by(id=1).first() - inst = to_dict(person, deep) - assert loads(response.data) == inst - - def test_post_m2m(self): - """Test for creating a new instance of the database model that has a - many to many relation that uses an association object to allow extra - info to be stored on the helper table. - - For more info, see issue #166. - - """ - vim = self.Program(name=u'Vim') - emacs = self.Program(name=u'Emacs') - self.session.add_all([vim, emacs]) - self.session.commit() - data = { - 'vendor': u'Apple', - 'name': u'iMac', - 'programs': [ - { - 'program_id': 1, - 'licensed': False - }, - { - 'program_id': 2, - 'licensed': True - } - ] - } - response = self.app.post('/api/computer', data=dumps(data)) - assert response.status_code == 201 - assert 'id' in loads(response.data) - response = self.app.get('/api/computer/1') - assert response.status_code == 200 - - def test_post_bad_parameter(self): - """Tests that attempting to make a :http:method:`post` request with a - form parameter which does not exist on the specified model responds - with an error message. - - """ - response = self.app.post('/api/person', data=dumps(dict(bogus=0))) - assert 400 == response.status_code - - response = self.app.post('/api/person', - data=dumps(dict(is_minor=True))) - assert 400 == response.status_code - - def test_post_nullable_date(self): - """Tests the creation of a model with a nullable date field.""" - self.manager.create_api(self.Star, methods=['GET', 'POST']) - data = dict(inception_time=None) - response = self.app.post('/api/star', data=dumps(data)) - assert response.status_code == 201 - response = self.app.get('/api/star/1') - assert response.status_code == 200 - assert loads(response.data)['inception_time'] is None - - def test_post_empty_date(self): - """Tests that attempting to assign an empty date string to a date field - actually assigns a value of ``None``. - - """ - self.manager.create_api(self.Star, methods=['GET', 'POST']) - data = dict(inception_time='') - response = self.app.post('/api/star', data=dumps(data)) - assert response.status_code == 201 - response = self.app.get('/api/star/1') - assert response.status_code == 200 - assert loads(response.data)['inception_time'] is None - - def test_post_date_functions(self): - """Tests that ``'CURRENT_TIMESTAMP'`` gets converted into a datetime - object when making a request to set a date or time field. - - """ - self.manager.create_api(self.Star, methods=['GET', 'POST']) - data = dict(inception_time='CURRENT_TIMESTAMP') - response = self.app.post('/api/star', data=dumps(data)) - assert response.status_code == 201 - response = self.app.get('/api/star/1') - assert response.status_code == 200 - inception_time = loads(response.data)['inception_time'] - assert inception_time is not None - inception_time = dateutil.parser.parse(inception_time) - diff = datetime.utcnow() - inception_time - assert diff.days == 0 - assert (diff.seconds + diff.microseconds / 1000000.0) < 3600 - - def test_serialize_time(self): - """Test for getting the JSON representation of a time field.""" - self.manager.create_api(self.User, primary_key='id') - now = datetime.now().time() - user = self.User(id=1, email='foo', wakeup=now) - self.session.add(user) - self.session.commit() - - response = self.app.get('/api/user/1') - assert response.status_code == 200 - data = loads(response.data) - assert data['wakeup'] == now.isoformat() - - def test_post_interval_functions(self): - oldJSONEncoder = self.flaskapp.json_encoder - - class IntervalJSONEncoder(oldJSONEncoder): - def default(self, obj): - if isinstance(obj, timedelta): - return int(obj.days * 86400 + obj.seconds) - return oldJSONEncoder.default(self, obj) - - self.flaskapp.json_encoder = IntervalJSONEncoder - - self.manager.create_api(self.Satellite, methods=['GET', 'POST']) - data = dict(name="Callufrax_Minor", period=300) - response = self.app.post('/api/satellite', data=dumps(data)) - assert response.status_code == 201 - response = self.app.get('/api/satellite/Callufrax_Minor') - assert response.status_code == 200 - assert loads(response.data)['period'] == 300 - satellite = self.session.query(self.Satellite).first() - assert satellite.period == timedelta(0, 300) - - def test_post_with_submodels(self): - """Tests the creation of a model with a related field.""" - data = {'name': u'John', 'age': 2041, - 'computers': [{'name': u'lixeiro', 'vendor': u'Lemote'}]} - response = self.app.post('/api/person', data=dumps(data)) - assert response.status_code == 201 - assert 'id' in loads(response.data) - - response = self.app.get('/api/person') - assert len(loads(response.data)['objects']) == 1 - - # Test with nested objects - data = {'name': 'Rodriguez', 'age': 70, - 'computers': [{'name': 'iMac', 'vendor': 'Apple', - 'programs': [{'program': {'name': 'iPhoto'}}]}]} - response = self.app.post('/api/person', data=dumps(data)) - assert 201 == response.status_code - response = self.app.get('/api/computer/2/programs') - programs = loads(response.data)['objects'] - assert programs[0]['program']['name'] == 'iPhoto' - - def test_post_unicode_primary_key(self): - """Test for creating a new instance of the database model using the - :http:method:`post` method with a Unicode primary key. - - """ - response = self.app.post('/api/user', data=dumps({'id': 1, - 'email': u'Юникод'})) - assert response.status_code == 201 - - def test_post_with_single_submodel(self): - data = {'vendor': u'Apple', 'name': u'iMac', - 'owner': {'name': u'John', 'age': 2041}} - response = self.app.post('/api/computer', data=dumps(data)) - assert response.status_code == 201 - assert 'id' in loads(response.data) - # Test if owner was successfully created - response = self.app.get('/api/person') - assert len(loads(response.data)['objects']) == 1 + # def test_post_invalid_json(self): + # # Invalid JSON in request data should respond with error. + # response = self.app.post('/api/person', data='Invalid JSON string') + # assert response.status_code == 400 + # assert loads(response.data)['message'] == 'Unable to decode data' + + # def test_post_integrity_error(self): + # # Test the integrity exception by violating the unique 'name' field of + # # the Person model. + # person = dict(name=u'foo') + # response = self.app.post('/api/person', data=dumps(person)) + # assert response.status_code == 201 + # response = self.app.post('/api/person', data=dumps(person)) + # assert response.status_code == 400 + # assert loads(response.data)['message'] == 'IntegrityError' + # assert self.session.is_active, "Session is in `partial rollback` state" + + # # For issue #158 we make sure that the previous failure is rolled back + # # so that we can add valid entries again + # person = dict(name=u'bar') + # response = self.app.post('/api/person', data=dumps(person)) + # assert response.status_code == 201 + # assert 'id' in loads(response.data) + # person = dict(name=u'bar') + # response = self.app.post('/api/person', data=dumps(person)) + # assert response.status_code == 201 + # assert 'id' in loads(response.data) + + # response = self.app.get('/api/person/1') + # assert response.status_code == 200 + + # deep = {'computers': [], 'projects': []} + # person = self.session.query(self.Person).filter_by(id=1).first() + # inst = to_dict(person, deep) + # assert loads(response.data) == inst + + # def test_post_m2m(self): + # """Test for creating a new instance of the database model that has a + # many to many relation that uses an association object to allow extra + # info to be stored on the helper table. + + # For more info, see issue #166. + + # """ + # vim = self.Program(name=u'Vim') + # emacs = self.Program(name=u'Emacs') + # self.session.add_all([vim, emacs]) + # self.session.commit() + # data = { + # 'vendor': u'Apple', + # 'name': u'iMac', + # 'programs': [ + # { + # 'program_id': 1, + # 'licensed': False + # }, + # { + # 'program_id': 2, + # 'licensed': True + # } + # ] + # } + # response = self.app.post('/api/computer', data=dumps(data)) + # assert response.status_code == 201 + # assert 'id' in loads(response.data) + # response = self.app.get('/api/computer/1') + # assert response.status_code == 200 + + # def test_post_bad_parameter(self): + # """Tests that attempting to make a :http:method:`post` request with a + # form parameter which does not exist on the specified model responds + # with an error message. + + # """ + # response = self.app.post('/api/person', data=dumps(dict(bogus=0))) + # assert 400 == response.status_code + + # response = self.app.post('/api/person', + # data=dumps(dict(is_minor=True))) + # assert 400 == response.status_code + + # def test_post_nullable_date(self): + # """Tests the creation of a model with a nullable date field.""" + # self.manager.create_api(self.Star, methods=['GET', 'POST']) + # data = dict(inception_time=None) + # response = self.app.post('/api/star', data=dumps(data)) + # assert response.status_code == 201 + # response = self.app.get('/api/star/1') + # assert response.status_code == 200 + # assert loads(response.data)['inception_time'] is None + + # def test_post_empty_date(self): + # """Tests that attempting to assign an empty date string to a date field + # actually assigns a value of ``None``. + + # """ + # self.manager.create_api(self.Star, methods=['GET', 'POST']) + # data = dict(inception_time='') + # response = self.app.post('/api/star', data=dumps(data)) + # assert response.status_code == 201 + # response = self.app.get('/api/star/1') + # assert response.status_code == 200 + # assert loads(response.data)['inception_time'] is None + + # def test_post_date_functions(self): + # """Tests that ``'CURRENT_TIMESTAMP'`` gets converted into a datetime + # object when making a request to set a date or time field. + + # """ + # self.manager.create_api(self.Star, methods=['GET', 'POST']) + # data = dict(inception_time='CURRENT_TIMESTAMP') + # response = self.app.post('/api/star', data=dumps(data)) + # assert response.status_code == 201 + # response = self.app.get('/api/star/1') + # assert response.status_code == 200 + # inception_time = loads(response.data)['inception_time'] + # assert inception_time is not None + # inception_time = dateutil.parser.parse(inception_time) + # diff = datetime.utcnow() - inception_time + # assert diff.days == 0 + # assert (diff.seconds + diff.microseconds / 1000000.0) < 3600 + + # def test_post_interval_functions(self): + # oldJSONEncoder = self.flaskapp.json_encoder + + # class IntervalJSONEncoder(oldJSONEncoder): + # def default(self, obj): + # if isinstance(obj, timedelta): + # return int(obj.days * 86400 + obj.seconds) + # return oldJSONEncoder.default(self, obj) + + # self.flaskapp.json_encoder = IntervalJSONEncoder + + # self.manager.create_api(self.Satellite, methods=['GET', 'POST']) + # data = dict(name="Callufrax_Minor", period=300) + # response = self.app.post('/api/satellite', data=dumps(data)) + # assert response.status_code == 201 + # response = self.app.get('/api/satellite/Callufrax_Minor') + # assert response.status_code == 200 + # assert loads(response.data)['period'] == 300 + # satellite = self.session.query(self.Satellite).first() + # assert satellite.period == timedelta(0, 300) + + # def test_post_with_submodels(self): + # """Tests the creation of a model with a related field.""" + # data = {'name': u'John', 'age': 2041, + # 'computers': [{'name': u'lixeiro', 'vendor': u'Lemote'}]} + # response = self.app.post('/api/person', data=dumps(data)) + # assert response.status_code == 201 + # assert 'id' in loads(response.data) + + # response = self.app.get('/api/person') + # assert len(loads(response.data)['objects']) == 1 + + # # Test with nested objects + # data = {'name': 'Rodriguez', 'age': 70, + # 'computers': [{'name': 'iMac', 'vendor': 'Apple', + # 'programs': [{'program': {'name': 'iPhoto'}}]}]} + # response = self.app.post('/api/person', data=dumps(data)) + # assert 201 == response.status_code + # response = self.app.get('/api/computer/2/programs') + # programs = loads(response.data)['objects'] + # assert programs[0]['program']['name'] == 'iPhoto' + + # def test_post_unicode_primary_key(self): + # """Test for creating a new instance of the database model using the + # :http:method:`post` method with a Unicode primary key. + + # """ + # response = self.app.post('/api/user', data=dumps({'id': 1, + # 'email': u'Юникод'})) + # assert response.status_code == 201 + + # def test_post_with_single_submodel(self): + # data = {'vendor': u'Apple', 'name': u'iMac', + # 'owner': {'name': u'John', 'age': 2041}} + # response = self.app.post('/api/computer', data=dumps(data)) + # assert response.status_code == 201 + # assert 'id' in loads(response.data) + # # Test if owner was successfully created + # response = self.app.get('/api/person') + # assert len(loads(response.data)['objects']) == 1 def test_patch_update_relations(self): """Test for posting a new model and simultaneously adding related @@ -1196,59 +1040,6 @@ def test_patch_autodelete_submodel(self): resp = self.app.get('/api/computer/1') assert resp.status_code == 404 - def test_pagination(self): - """Tests for pagination of long result sets.""" - self.manager.create_api(self.Person, url_prefix='/api/v2', - results_per_page=5) - self.manager.create_api(self.Person, url_prefix='/api/v3', - results_per_page=0) - for i in range(25): - d = dict(name='person{0}'.format(i)) - response = self.app.post('/api/person', data=dumps(d)) - assert response.status_code == 201 - - response = self.app.get('/api/person') - assert response.status_code == 200 - assert loads(response.data)['page'] == 1 - assert len(loads(response.data)['objects']) == 10 - assert loads(response.data)['total_pages'] == 3 - - response = self.app.get('/api/person?page=1') - assert response.status_code == 200 - assert loads(response.data)['page'] == 1 - assert len(loads(response.data)['objects']) == 10 - assert loads(response.data)['total_pages'] == 3 - - response = self.app.get('/api/person?page=2') - assert response.status_code == 200 - assert loads(response.data)['page'] == 2 - assert len(loads(response.data)['objects']) == 10 - assert loads(response.data)['total_pages'] == 3 - - response = self.app.get('/api/person?page=3') - assert response.status_code == 200 - assert loads(response.data)['page'] == 3 - assert len(loads(response.data)['objects']) == 5 - assert loads(response.data)['total_pages'] == 3 - - response = self.app.get('/api/v2/person?page=3') - assert response.status_code == 200 - assert loads(response.data)['page'] == 3 - assert len(loads(response.data)['objects']) == 5 - assert loads(response.data)['total_pages'] == 5 - - response = self.app.get('/api/v3/person') - assert response.status_code == 200 - assert loads(response.data)['page'] == 1 - assert len(loads(response.data)['objects']) == 25 - assert loads(response.data)['total_pages'] == 1 - - response = self.app.get('/api/v3/person?page=2') - assert response.status_code == 200 - assert loads(response.data)['page'] == 1 - assert len(loads(response.data)['objects']) == 25 - assert loads(response.data)['total_pages'] == 1 - def test_num_results(self): """Tests that a request for (a subset of) all instances of a model includes the total number of results as part of the JSON response. @@ -1262,161 +1053,37 @@ def test_num_results(self): response = self.app.get('/api/person') assert response.status_code == 200 data = loads(response.data) - assert 'num_results' in data - assert data['num_results'] == 15 - - def test_alternate_primary_key(self): - """Tests that models with primary keys which are not ``id`` columns are - accessible via their primary keys. - - """ - self.manager.create_api(self.Planet, methods=['GET', 'POST']) - response = self.app.post('/api/planet', data=dumps(dict(name='Earth'))) - assert response.status_code == 201 - response = self.app.get('/api/planet/1') - assert response.status_code == 404 - response = self.app.get('/api/planet') - assert response.status_code == 200 - assert len(loads(response.data)['objects']) == 1 - response = self.app.get('/api/planet/Earth') - assert response.status_code == 200 - assert loads(response.data) == dict(name='Earth') - - def test_specified_primary_key(self): - """Tests that models with more than one primary key are - accessible via a specified primary key. - - """ - self.manager.create_api(self.User, methods=['GET', 'POST', 'PATCH'], - primary_key='email') - data = dict(id=1, email='foo', wakeup=None) - response = self.app.post('/api/user', data=dumps(data)) - assert response.status_code == 201 - response = self.app.get('/api/user/1') - assert response.status_code == 404 - response = self.app.get('/api/user') - assert response.status_code == 200 - assert len(loads(response.data)['objects']) == 1 - response = self.app.get('/api/user/foo') - assert response.status_code == 200 - assert loads(response.data) == data - response = self.app.patch('/api/user/foo', data=dumps(dict(id=2)), - content_type='application/json') - assert 200 == response.status_code - assert loads(response.data)['id'] == 2 - - def test_post_form_preprocessor(self): - """Tests POST method decoration using a custom function.""" - def decorator_function(data=None, **kw): - if data: - data['other'] = 7 - - # test for function that decorates parameters with 'other' attribute - self.manager.create_api(self.Person, methods=['POST'], - url_prefix='/api/v2', - post_form_preprocessor=decorator_function) - - response = self.app.post('/api/v2/person', - data=dumps({'name': u'Lincoln', 'age': 23})) - assert response.status_code == 201 - - personid = loads(response.data)['id'] - person = self.session.query(self.Person).filter_by(id=personid).first() - assert person.other == 7 - - def test_results_per_page(self): - """Tests that the client can correctly specify the number of results - appearing per page, in addition to specifying which page of results to - return. - - """ - self.manager.create_api(self.Person, methods=['POST', 'GET']) - for n in range(25): - response = self.app.post('/api/person', data=dumps({})) - assert 201 == response.status_code - response = self.app.get('/api/person?results_per_page=20') - assert 200 == response.status_code - data = loads(response.data) - assert 20 == len(data['objects']) - # Fall back to default number of results per page on bad requests. - response = self.app.get('/api/person?results_per_page=-1') - assert 200 == response.status_code - data = loads(response.data) - assert 10 == len(data['objects']) - # Only return max number of results per page. - response = self.app.get('/api/person?results_per_page=30') - assert 200 == response.status_code - data = loads(response.data) - assert 25 == len(data['objects']) - - def test_get_string_pk(self): - """Tests for getting a row which has a string primary key, including - the possibility of a string representation of a number. - - """ - # create a model and an instance of the model - class StringID(self.Base): - __tablename__ = 'stringid' - name = Column(Unicode, primary_key=True) - self.Base.metadata.create_all() - self.manager.create_api(StringID) - - foo = StringID(name=u'1') - self.session.add(foo) - self.session.commit() - response = self.app.get('/api/stringid/1') - assert 200 == response.status_code - data = loads(response.data) - assert 'name' in data - assert '1' == data['name'] - response = self.app.get('/api/stringid/01') - assert 404 == response.status_code - - bar = StringID(name=u'01') - self.session.add(bar) - self.session.commit() - response = self.app.get('/api/stringid/01') - assert 200 == response.status_code - data = loads(response.data) - assert 'name' in data - assert '01' == data['name'] - - baz = StringID(name=u'hey') - self.session.add(baz) - self.session.commit() - response = self.app.get('/api/stringid/hey') - assert 200 == response.status_code - data = loads(response.data) - assert 'name' in data - assert 'hey' == data['name'] - - def test_jsonp(self): - """Test for JSON-P callbacks.""" - person1 = self.Person(name=u'foo') - person2 = self.Person(name=u'bar') - self.session.add_all([person1, person2]) - self.session.commit() - # test for GET - response = self.app.get('/api/person/1?callback=baz') - assert 200 == response.status_code - assert response.data.startswith(b'baz(') - assert response.data.endswith(b')') - # test for search - response = self.app.get('/api/person?callback=baz') - assert 200 == response.status_code - assert response.data.startswith(b'baz(') - assert response.data.endswith(b')') - - def test_duplicate_post(self): - """Tests for making a :http:method:`post` request with data that - already exists in the database. - - """ - data = dict(name='test') - response = self.app.post('/api/person', data=dumps(data)) - assert 201 == response.status_code - response = self.app.post('/api/person', data=dumps(data)) - assert 400 == response.status_code + assert data['meta']['num_results'] == 15 + + # def test_post_form_preprocessor(self): + # """Tests POST method decoration using a custom function.""" + # def decorator_function(data=None, **kw): + # if data: + # data['other'] = 7 + + # # test for function that decorates parameters with 'other' attribute + # self.manager.create_api(self.Person, methods=['POST'], + # url_prefix='/api/v2', + # post_form_preprocessor=decorator_function) + + # response = self.app.post('/api/v2/person', + # data=dumps({'name': u'Lincoln', 'age': 23})) + # assert response.status_code == 201 + + # personid = loads(response.data)['id'] + # person = self.session.query(self.Person).filter_by(id=personid).first() + # assert person.other == 7 + + # def test_duplicate_post(self): + # """Tests for making a :http:method:`post` request with data that + # already exists in the database. + + # """ + # data = dict(name='test') + # response = self.app.post('/api/person', data=dumps(data)) + # assert 201 == response.status_code + # response = self.app.post('/api/person', data=dumps(data)) + # assert 400 == response.status_code def test_delete_from_relation(self): """Tests that a :http:method:`delete` request to a related instance @@ -1457,51 +1124,6 @@ def test_delete_from_relation(self): # assert response.status_code == 200 # assert len(loads(response.data)['objects']) == 0 - def test_get_callable_query_attribute(self): - """Tests that a callable model.query attribute is being used - when available. - - """ - # create aliases for the sake of brevity - CarModel, CarManufacturer = self.CarModel, self.CarManufacturer - - # create some example car manufacturers and models - manufacturer_name = u'Super Cars Ltd.' - cm1 = CarManufacturer(name=manufacturer_name) - cm2 = CarManufacturer(name=u'Trash Cars Ltd.') - self.session.add_all((cm1, cm2)) - - car1 = CarModel(name=u'Luxory deluxe L', manufacturer=cm1) - car2 = CarModel(name=u'Luxory deluxe XL', manufacturer=cm1) - car3 = CarModel(name=u'Broken wheel', manufacturer=cm2) - self.session.add_all((car1, car2, car3)) - self.session.commit() - - # create a custom query method for the CarModel class - def query(cls): - car_model = self.session.query(cls) - name_filter = (CarManufacturer.name == manufacturer_name) - return car_model.join(CarManufacturer).filter(name_filter) - CarModel.query = classmethod(query) - - response = self.app.get('/api/car_model') - assert 200 == response.status_code - data = loads(response.data) - assert 2 == len(data['objects']) - - for car in data['objects']: - assert car['manufacturer']['name'] == manufacturer_name - - for car in [car1, car2]: - response = self.app.get('/api/car_model/{0}'.format(car.id)) - assert 200 == response.status_code - data = loads(response.data) - assert data['manufacturer_id'] == cm1.id - assert data['name'] == car.name - - response = self.app.get('/api/car_model/{0}'.format(car3.id)) - assert 404 == response.status_code - def test_set_hybrid_property(self): """Tests that a hybrid property can be correctly set by a client.""" @@ -1562,396 +1184,6 @@ def test_patch_with_hybrid_property(self): assert 10 == data['height'] assert 50 == data['number_of_pixels'] - -class TestHeaders(TestSupportPrefilled): - """Tests for correct HTTP headers in responses.""" - - def setUp(self): - super(TestHeaders, self).setUp() - self.manager.create_api(self.Person, methods=['GET', 'POST', 'PATCH']) - - def test_post_location(self): - """Tests that a :http:method:`post` request responds with the correct - ``Location`` header. - - """ - response = self.app.post('/api/person', data=dumps({})) - assert 201 == response.status_code - assert 'Location' in response.headers - # there are five existing people - expected = 'http://localhost/api/person/6' - actual = response.headers['Location'] - assert expected == actual - - def test_pagination_links(self): - """Tests that a :http:method:`get` request that would respond with a - paginated list of results returns the appropriate ``Link`` headers. - - """ - response = self.app.get('/api/person?page=2&results_per_page=1') - assert 200 == response.status_code - assert 'Link' in response.headers - links = response.headers['Link'] - # next page - assert 'page=3' in links - assert 'rel="next"' in links - # last page - assert 'page=5' in links - assert 'rel="last"' in links - - def test_content_type(self): - """Tests that the server responds only to requests with a JSON - Content-Type. - - """ - # A request that does not require a body without a Content-Type headers - # should be OK either way. - response = self.app.get('/api/person/1', content_type=None) - assert 200 == response.status_code - response = self.app.get('/api/person/1', - content_type='application/json') - assert 200 == response.status_code - # A request that requires a body but without a Content-Type header - # should produce an error (specifically, error 415 Unsupported media - # type). - response = self.app.post('/api/person', data=dumps(dict(name='foo')), - content_type=None) - assert 415 == response.status_code - response = self.app.post('/api/person', data=dumps(dict(name='foo')), - content_type='application/json') - assert 201 == response.status_code - # A request without an Accept header should return JSON. - assert 'Content-Type' in response.headers - assert 'application/json' == response.headers['Content-Type'] - assert 'foo' == loads(response.data)['name'] - response = self.app.post('/api/person', data=dumps(dict(name='foo')), - content_type=None) - assert 415 == response.status_code - # Same goes for a PATCH request. - response = self.app.patch('/api/person/6', data=dumps(dict(name='x')), - content_type=None) - assert 415 == response.status_code - response = self.app.patch('/api/person/6', data=dumps(dict(name='x')), - content_type='application/json') - assert 200 == response.status_code - content_type = 'application/json; charset=UTF-8' - response = self.app.patch('/api/person/6', data=dumps(dict(name='x')), - content_type=content_type) - assert 200 == response.status_code - - # A request without an Accept header should return JSON. - assert 'Content-Type' in response.headers - assert 'application/json' == response.headers['Content-Type'] - assert 'x' == loads(response.data)['name'] - - def test_content_type_msie(self): - """Tests for compatibility with Microsoft Internet Explorer 8 and 9. - - According to issue #267, making requests using JavaScript from these - web browsers does not allow changing the content type of the request - (it is always ``text/html``). Therefore :http:method:`post` and - :http:method:`patch` should ignore the content type when a request is - coming from these old browsers. - - """ - # Test for Microsoft Internet Explorer 8. - headers = {'User-Agent': '{0}'.format(MSIE8_UA)} - content_type = 'text/html' - data = dict(name=u'foo') - response = self.app.post('/api/person', data=dumps(data), - headers=headers, content_type=content_type) - assert response.status_code == 201 - person = loads(response.data) - assert person['name'] == 'foo' - personid = person['id'] - data = dict(name=u'bar') - response = self.app.patch('/api/person/{0}'.format(personid), - data=dumps(data), headers=headers, - content_type=content_type) - assert response.status_code == 200 - person = loads(response.data) - assert person['name'] == 'bar' - - # Test for Microsoft Internet Explorer 9. - headers = {'User-Agent': '{0}'.format(MSIE9_UA)} - data = dict(name=u'foo2') - response = self.app.post('/api/person', data=dumps(data), - headers=headers, content_type=content_type) - assert response.status_code == 201 - personid = loads(response.data)['id'] - data = dict(name=u'bar2') - response = self.app.patch('/api/person/{0}'.format(personid), - data=dumps(data), headers=headers, - content_type=content_type) - assert response.status_code == 200 - assert loads(response.data)['name'] == 'bar2' - - def test_accept(self): - """Tests that the server responds to the ``Accept`` with a response of - the correct content-type. - - """ - # A request without an Accept header should return JSON. - headers = None - response = self.app.get('/api/person/1', headers=headers) - assert 200 == response.status_code - assert 'Content-Type' in response.headers - assert 'application/json' == response.headers['Content-Type'] - assert 1 == loads(response.data)['id'] - headers = dict(Accept='application/json') - response = self.app.get('/api/person/1', headers=headers) - assert 200 == response.status_code - assert 'Content-Type' in response.headers - assert 'application/json' == response.headers['Content-Type'] - assert 1 == loads(response.data)['id'] - # Check for accepting XML. - # headers = dict(Accept='application/xml') - # response = self.app.get('/api/person/1', headers=headers) - # assert 200 == response.status_code - # assert 'Content-Type' in response.headers - # assert 'application/xml' == response.headers['Content-Type'] - # assert '1' in response.data - - -class TestSearch(TestSupportPrefilled): - """Unit tests for the search query functionality.""" - - def setUp(self): - """Creates the database, the :class:`~flask.Flask` object, the - :class:`~flask_restless.manager.APIManager` for that application, and - creates the ReSTful API endpoints for the :class:`testapp.Person` and - :class:`testapp.Computer` models. - - """ - super(TestSearch, self).setUp() - self.manager.create_api(self.Person, methods=['GET', 'PATCH']) - self.app.search = lambda url, q: self.app.get(url + '?q={0}'.format(q)) - - def test_search(self): - """Tests basic search using the :http:method:`get` method.""" - # Trying to pass invalid params to the search method - resp = self.app.get('/api/person?q=Test') - assert resp.status_code == 400 - assert loads(resp.data)['message'] == 'Unable to decode data' - - search = {'filters': [{'name': 'name', 'val': '%y%', 'op': 'like'}]} - # Let's search for users with that above filter - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - loaded = loads(resp.data) - assert len(loaded['objects']) == 3 # Mary, Lucy and Katy - - # Tests searching for a single row - search = { - 'single': True, # I'm sure we have only one row here - 'filters': [ - {'name': 'name', 'val': u'Lincoln', 'op': 'equals'} - ], - } - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert loads(resp.data)['name'] == u'Lincoln' - - # Looking for something that does not exist on the database - search['filters'][0]['val'] = 'Sammy' - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 404 - assert loads(resp.data)['message'] == 'No result found' - - # We have to receive an error if the user provides an invalid - # data to the search, like this: - search = { - 'filters': [ - {'name': 'age', 'val': 'It should not be a string', 'op': 'gt'} - ] - } - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert len(loads(resp.data)['objects']) == 0 - - # Testing the order_by stuff - search = {'order_by': [{'field': 'age', 'direction': 'asc'}]} - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - loaded = loads(resp.data)['objects'] - assert loaded[0][u'age'] == 7 - assert loaded[1][u'age'] == 19 - assert loaded[2][u'age'] == 23 - assert loaded[3][u'age'] == 25 - assert loaded[4][u'age'] == 28 - - # Test the IN operation - search = { - 'filters': [ - {'name': 'age', 'val': [7, 28], 'op': 'in'} - ] - } - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - loaded = loads(resp.data)['objects'] - assert loaded[0][u'age'] == 7 - assert loaded[1][u'age'] == 28 - - # Testing related search - update = { - 'computers': { - 'add': [{'name': u'lixeiro', 'vendor': u'Lenovo'}] - } - } - resp = self.app.patch('/api/person/1', data=dumps(update)) - assert resp.status_code == 200 - - # TODO document this - search = { - 'single': True, - 'filters': [ - {'name': 'computers__name', - 'val': u'lixeiro', - 'op': 'any'} - ] - } - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert loads(resp.data)['computers'][0]['name'] == 'lixeiro' - - # Testing the comparation for two fields. We want to compare - # `age' and `other' fields. If the first one is lower than or - # equals to the second one, we want the object - search = { - 'filters': [ - {'name': 'age', 'op': 'lte', 'field': 'other'} - ], - 'order_by': [ - {'field': 'other'} - ] - } - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - loaded = loads(resp.data)['objects'] - assert len(loaded) == 2 - assert loaded[0]['other'] == 10 - assert loaded[1]['other'] == 19 - - def test_search2(self): - """Testing more search functionality.""" - # Let's test the search using an id - search = { - 'single': True, - 'filters': [{'name': 'id', 'op': 'equal_to', 'val': 1}] - } - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert loads(resp.data)['name'] == u'Lincoln' - - # Testing limit and offset - search = {'limit': 1, 'offset': 1} - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert 1 == len(loads(resp.data)['objects']) - assert loads(resp.data)['num_results'] == 1 - assert loads(resp.data)['objects'][0]['name'] == u'Mary' - - # Testing limit by itself - search = {'limit': 1} - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert 1 == len(loads(resp.data)['objects']) - assert loads(resp.data)['num_results'] == 1 - assert loads(resp.data)['objects'][0]['name'] == u'Lincoln' - - search = {'limit': 5000} - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert 5 == len(loads(resp.data)['objects']) - assert loads(resp.data)['num_results'] == 5 - assert loads(resp.data)['objects'][0]['name'] == u'Lincoln' - - # Testing offset by itself - search = {'offset': 1} - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 200 - assert 4 == len(loads(resp.data)['objects']) - assert loads(resp.data)['num_results'] == 4 - assert loads(resp.data)['objects'][0]['name'] == u'Mary' - - # Testing multiple results when calling .one() - resp = self.app.search('/api/person', dumps({'single': True})) - assert resp.status_code == 400 - assert loads(resp.data)['message'] == 'Multiple results found' - - def test_search_dates(self): - """Test date parsing""" - # Lincoln has been allocated a birthday of 1900-01-02. - # We'll ask for dates in a variety of formats, including invalid ones. - search = { - 'single': True, - 'filters': [{'name': 'birth_date', 'op': 'eq'}] - } - - # 1900-01-02 - search['filters'][0]['val'] = '1900-01-02' - resp = self.app.search('/api/person', dumps(search)) - assert loads(resp.data)['name'] == u'Lincoln' - - # 2nd Jan 1900 - search['filters'][0]['val'] = '2nd Jan 1900' - resp = self.app.search('/api/person', dumps(search)) - assert loads(resp.data)['name'] == u'Lincoln' - - # Invalid Date - search['filters'][0]['val'] = 'REALLY-BAD-DATE' - resp = self.app.search('/api/person', dumps(search)) - assert resp.status_code == 400 - - # DateTime - # This will be cropped to a date, since birth_date is a Date column - search['filters'][0]['val'] = '2nd Jan 1900 14:35' - resp = self.app.search('/api/person', dumps(search)) - assert loads(resp.data)['name'] == u'Lincoln' - - def test_search_boolean_formula(self): - """Tests for Boolean formulas of filters in a search query.""" - # This searches for people whose name is John, or people older than age - # 10 who have a "y" in their names. - # - # According to the pre-filled database, this should return three - # people: John, Lucy, and Mary. - data = {'filters': - [{'or': - [{'and': - [dict(name='name', op='like', val='%y%'), - dict(name='age', op='ge', val=10)]}, - dict(name='name', op='eq', val='John') - ] - }] - } - response = self.app.search('/api/person', dumps(data)) - assert 200 == response.status_code - data = loads(response.data)['objects'] - assert 3 == len(data) - assert set(['Lucy', 'Mary', 'John']) == \ - set([person['name'] for person in data]) - - def test_search_bad_arguments(self): - """Tests that search requests with bad parameters respond with an error - message. - - """ - # missing argument - d = dict(filters=[dict(name='name', op='==')]) - resp = self.app.search('/api/person', dumps(d)) - assert resp.status_code == 400 - - # missing operator - d = dict(filters=[dict(name='name', val='Test')]) - resp = self.app.search('/api/person', dumps(d)) - assert resp.status_code == 400 - - # missing fieldname - d = dict(filters=[dict(op='==', val='Test')]) - resp = self.app.search('/api/person', dumps(d)) - assert resp.status_code == 400 - def test_like(self): """Tests for the like operator.""" person1 = self.Person(name='foo') @@ -2240,45 +1472,6 @@ def test_patch_with_remove(self): self._check_relations() - def test_any(self): - """Tests that a search query correctly searches fields on an associated - model. - - """ - self.session.add(self.Product()) - self.session.add(self.Image()) - self.session.add(self.Image()) - self.session.commit() - - data = {'chosen_images': [{'id': 1}, {'id': 2}]} - response = self.app.patch('/api/product/1', data=dumps(data)) - assert response.status_code == 200 - - filters = {'filters': [{'name': 'chosen_images__id', 'op': 'any', - 'val': 1}]} - response = self.app.get('/api/product?q=' + dumps(filters)) - assert response.status_code == 200 - data = loads(response.data) - assert {'id': 1} in data['objects'][0]['chosen_images'] - - data = {'chosen_images': {'remove': [{'id': 1}]}} - response = self.app.patch('/api/product/1', data=dumps(data)) - assert response.status_code == 200 - - filters = {'filters': [{'name': 'chosen_images__id', 'op': 'any', - 'val': 1}]} - response = self.app.get('/api/product?q=' + dumps(filters)) - assert response.status_code == 200 - data = loads(response.data) - assert data['num_results'] == 0 - - filters = {'filters': [{'name': 'chosen_images', 'op': 'any', - 'val': {'name': 'id', 'op': 'eq', 'val': 1}}]} - response = self.app.get('/api/product?q=' + dumps(filters)) - assert response.status_code == 200 - data = loads(response.data) - assert data['num_results'] == 0 - def test_scalar(self): """Tests that association proxies to remote scalar attributes work correctly.