diff --git a/CHANGES.md b/CHANGES.md index ad62544..71cd9e0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,9 @@ # SQLAlchemy-JSONAPI Changelog -## 5.0.0 +## 4.0.9 *Unreleased* -* BREAKING: The query data is now expected for all endpoints * Fixed bug during testing in delete_relationship where returned resource was missing data key * Fixed bug during testing in patch_resource where field check was failing diff --git a/README.md b/README.md index f74e78d..476c826 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ [![Build Status](https://travis-ci.org/ColtonProvias/sqlalchemy-jsonapi.svg?branch=master)](https://travis-ci.org/ColtonProvias/sqlalchemy-jsonapi) -**WARNING: The master branch is currently breaking backwards compatibility and thus has been bumped to 5.0.0. Builds are likely to fail during 5.0.0 development.** - [JSON API](http://jsonapi.org/) implementation for use with [SQLAlchemy](http://www.sqlalchemy.org/). diff --git a/docs/constants.rst b/docs/constants.rst deleted file mode 100644 index 397b73b..0000000 --- a/docs/constants.rst +++ /dev/null @@ -1,11 +0,0 @@ -========= -Constants -========= - -.. currentmodule:: sqlalchemy_jsonapi.constants - -.. autoclass:: Method - :members: - -.. autoclass:: Endpoint - :members: diff --git a/docs/errors.rst b/docs/errors.rst index 6f67af2..8571920 100644 --- a/docs/errors.rst +++ b/docs/errors.rst @@ -1,47 +1,3 @@ ====== Errors -====== - -.. currentmodule:: sqlalchemy_jsonapi.errors - -.. autoclass:: BaseError - :members: - -.. autoclass:: BadRequestError - :members: - -.. autoclass:: NotAnAttributeError - :members: - -.. autoclass:: NotSortableError - :members: - -.. autoclass:: PermissionDeniedError - :members: - -.. autoclass:: InvalidTypeForEndpointError - :members: - -.. autoclass:: MissingTypeError - :members: - -.. autoclass:: MissingContentTypeError - :members: - -.. autoclass:: ValidationError - :members: - -.. autoclass:: ResourceNotFoundError - :members: - -.. autoclass:: RelatedResourceNotFoundError - :members: - -.. autoclass:: RelationshipNotFoundError - :members: - -.. autoclass:: ToManyExpectedError - :members: - -.. autoclass:: ResourceTypeNotFoundError - :members: +====== \ No newline at end of file diff --git a/docs/flask.rst b/docs/flask.rst index 44c252e..4f2165b 100644 --- a/docs/flask.rst +++ b/docs/flask.rst @@ -2,7 +2,7 @@ Flask ===== -.. currentmodule:: sqlalchemy_jsonapi.flaskext +.. currentmodule:: sqlalchemy_jsonapi.flask To those who use Flask, setting up SQLAlchemy-JSONAPI can be extremely complex and frustrating. Let's look at an example:: diff --git a/docs/index.rst b/docs/index.rst index b6a6a2f..229000c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,7 +12,6 @@ Contents: serializer flask errors - constants Indices and tables diff --git a/docs/serializer.rst b/docs/serializer.rst index 4f85a59..d0bdb09 100644 --- a/docs/serializer.rst +++ b/docs/serializer.rst @@ -1,8 +1,3 @@ ========== Serializer -========== - -.. currentmodule:: sqlalchemy_jsonapi.serializer - -.. autoclass:: JSONAPI - :members: +========== \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 002def7..9b44940 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ # # pip-compile --output-file requirements.txt requirements.in # + bcrypt==2.0.0 blinker==1.4 cffi==1.7.0 # via bcrypt diff --git a/setup.py b/setup.py index 5579599..4f72f56 100644 --- a/setup.py +++ b/setup.py @@ -14,18 +14,14 @@ from setuptools import setup import sys -import ast requirements = ['SQLAlchemy', 'inflection'] -with open('sqlalchemy_jsonapi/_version.py', 'r') as f: - version = ast.parse(f.read()).body[0].value.s - if sys.version_info[0] != 3 or sys.version_info[1] < 4: - requirements.append('enum34') + requirements.append('enum34') setup(name='SQLAlchemy-JSONAPI', - version=version, + version='4.0.9', url='http://github.com/coltonprovias/sqlalchemy-jsonapi', license='MIT', author='Colton J. Provias', diff --git a/sqlalchemy_jsonapi/__init__.py b/sqlalchemy_jsonapi/__init__.py index 1e3996c..9f9fd7f 100644 --- a/sqlalchemy_jsonapi/__init__.py +++ b/sqlalchemy_jsonapi/__init__.py @@ -3,9 +3,8 @@ AttributeActions, Permissions, RelationshipActions, attr_descriptor, permission_test, relationship_descriptor) -from ._version import __version__ try: from .flaskext import FlaskJSONAPI except ImportError: - FlaskJSONAPI = None + FlaskJSONAPI = None \ No newline at end of file diff --git a/sqlalchemy_jsonapi/_version.py b/sqlalchemy_jsonapi/_version.py deleted file mode 100644 index a0f6658..0000000 --- a/sqlalchemy_jsonapi/_version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '5.0.0' diff --git a/sqlalchemy_jsonapi/constants.py b/sqlalchemy_jsonapi/constants.py index b1501a9..c7b519a 100644 --- a/sqlalchemy_jsonapi/constants.py +++ b/sqlalchemy_jsonapi/constants.py @@ -5,6 +5,7 @@ MIT License """ + try: from enum import Enum except ImportError: @@ -26,4 +27,4 @@ class Endpoint(Enum): COLLECTION = '/' RESOURCE = '//' RELATED = '///' - RELATIONSHIP = '///relationships/' + RELATIONSHIP = '///relationships/' \ No newline at end of file diff --git a/sqlalchemy_jsonapi/flaskext.py b/sqlalchemy_jsonapi/flaskext.py index 1e8f6ce..2548d32 100644 --- a/sqlalchemy_jsonapi/flaskext.py +++ b/sqlalchemy_jsonapi/flaskext.py @@ -163,11 +163,7 @@ def _setup_adapter(self, namespace, route_prefix): :param namespace: Prefix for generated endpoints :param route_prefix: Prefix for route patterns """ - self.serializer = JSONAPI(self.sqla.Model, - prefix='{}://{}{}'.format( - self.app.config['PREFERRED_URL_SCHEME'], - self.app.config['SERVER_NAME'], - route_prefix)) + self.serializer = JSONAPI(self.sqla.Model, prefix='{}://{}{}'.format(self.app.config['PREFERRED_URL_SCHEME'], self.app.config['SERVER_NAME'], route_prefix)) for view in views: method, endpoint = view pattern = route_prefix + endpoint.value @@ -224,8 +220,8 @@ def new_view(**kwargs): try: attr = '{}_{}'.format(method.name, endpoint.name).lower() handler = getattr(self.serializer, attr) - handler_chain = list(self._handler_chains.get((kwargs[ - 'api_type'], method, endpoint), [])) + handler_chain = list(self._handler_chains.get(( + kwargs['api_type'], method, endpoint), [])) handler_chain.append(handler) chained_handler = self._call_next(handler_chain) response = chained_handler(*args) diff --git a/sqlalchemy_jsonapi/serializer.py b/sqlalchemy_jsonapi/serializer.py index 74c101e..16dcef1 100644 --- a/sqlalchemy_jsonapi/serializer.py +++ b/sqlalchemy_jsonapi/serializer.py @@ -1,39 +1,48 @@ -"""SQLAlchemy-JSONAPI Serializer. - +""" +SQLAlchemy-JSONAPI +Serializer Colton J. Provias MIT License """ +from collections import MutableMapping from enum import Enum -from inflection import dasherize, tableize, underscore +from inflection import pluralize, dasherize, parameterize, tableize, underscore from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.interfaces import MANYTOONE from sqlalchemy.util.langhelpers import iterate_attributes +from pprint import pprint from .errors import (BadRequestError, InvalidTypeForEndpointError, MissingTypeError, NotSortableError, PermissionDeniedError, RelationshipNotFoundError, ResourceNotFoundError, ResourceTypeNotFoundError, ToManyExpectedError, ValidationError) -from ._version import __version__ -class Actions(Enum): - """ The actions that can be performed on an attribute or relationship. """ +class AttributeActions(Enum): + """ The actions that can be done to an attribute. """ + + GET = 0 + SET = 1 + - GET = 1 - APPEND = 2 - SET = 3 - REMOVE = 4 +class RelationshipActions(Enum): + """ The actions that can be performed on a relationship. """ + + GET = 10 + APPEND = 11 + SET = 12 + DELETE = 13 class Permissions(Enum): """ The permissions that can be set. """ - VIEW = 1 - CREATE = 2 - EDIT = 3 - DELETE = 4 + VIEW = 100 + CREATE = 101 + EDIT = 102 + DELETE = 103 ALL_PERMISSIONS = { @@ -44,15 +53,45 @@ class Permissions(Enum): } -def jsonapi_action(action, *names): - if isinstance(action, Actions): +def attr_descriptor(action, *names): + """ + Wrap a function that allows for getting or setting of an attribute. This + allows for specific handling of an attribute when it comes to serializing + and deserializing. + + :param action: The AttributeActions that this descriptor performs + :param names: A list of names of the attributes this references + """ + if isinstance(action, AttributeActions): action = [action] def wrapped(fn): if not hasattr(fn, '__jsonapi_action__'): fn.__jsonapi_action__ = set() - fn.__jsonapi_desc__ = set() - fn.__jsonapi_desc__ |= set(names) + fn.__jsonapi_desc_for_attrs__ = set() + fn.__jsonapi_desc_for_attrs__ |= set(names) + fn.__jsonapi_action__ |= set(action) + return fn + + return wrapped + + +def relationship_descriptor(action, *names): + """ + Wrap a function for modification of a relationship. This allows for + specific handling for serialization and deserialization. + + :param action: The RelationshipActions that this descriptor performs + :param names: A list of names of the relationships this references + """ + if isinstance(action, RelationshipActions): + action = [action] + + def wrapped(fn): + if not hasattr(fn, '__jsonapi_action__'): + fn.__jsonapi_action__ = set() + fn.__jsonapi_desc_for_rels__ = set() + fn.__jsonapi_desc_for_rels__ |= set(names) fn.__jsonapi_action__ |= set(action) return fn @@ -60,10 +99,12 @@ def wrapped(fn): class PermissionTest(object): - """Authorize access to a model, resource, or specific field.""" + """ Authorize access to a model, resource, or specific field. """ def __init__(self, permission, *names): - """Decorate a function that returns a boolean representing access. + """ + Decorates a function that returns a boolean representing if access is + allowed. :param permission: The permission to check for :param names: The names to test for. None represents the model. @@ -88,18 +129,18 @@ def __call__(self, fn): return fn #: More consistent name for the decorators -jsonapi_access = PermissionTest +permission_test = PermissionTest class JSONAPIResponse(object): - """Wrapper for JSON API Responses.""" + """ Wrapper for JSON API Responses. """ def __init__(self): - """Default the status code and data.""" + """ Default the status code and data. """ self.status_code = 200 self.data = { 'jsonapi': {'version': '1.0'}, - 'meta': {'sqlalchemy_jsonapi_version': __version__} + 'meta': {'sqlalchemy_jsonapi_version': '4.0.9'} } @@ -118,7 +159,8 @@ def get_permission_test(model, field, permission, instance=None): def check_permission(instance, field, permission): """ - Check a permission for a given instance or field. Raises error if denied. + Check a permission for a given instance or field. Raises an error if + denied. :param instance: The instance to check :param field: The field name to check or None for instance @@ -134,10 +176,10 @@ def get_attr_desc(instance, attribute, action): :param instance: Model instance :param attribute: Name of the attribute - :param action: Action + :param action: AttributeAction """ descs = instance.__jsonapi_attribute_descriptors__.get(attribute, {}) - if action == Actions.GET: + if action == AttributeActions.GET: check_permission(instance, attribute, Permissions.VIEW) return descs.get(action, lambda x: getattr(x, attribute)) check_permission(instance, attribute, Permissions.EDIT) @@ -153,13 +195,13 @@ def get_rel_desc(instance, key, action): :param action: RelationshipAction """ descs = instance.__jsonapi_rel_desc__.get(key, {}) - if action == Actions.GET: + if action == RelationshipActions.GET: check_permission(instance, key, Permissions.VIEW) return descs.get(action, lambda x: getattr(x, key)) - elif action == Actions.APPEND: + elif action == RelationshipActions.APPEND: check_permission(instance, key, Permissions.CREATE) return descs.get(action, lambda x, v: getattr(x, key).append(v)) - elif action == Actions.SET: + elif action == RelationshipActions.SET: check_permission(instance, key, Permissions.EDIT) return descs.get(action, lambda x, v: setattr(x, key, v)) else: @@ -185,8 +227,7 @@ def __init__(self, base, prefix=''): continue prepped_name = self._api_type_for_model(model) - api_type = getattr(model, '__jsonapi_type_override__', - prepped_name) + api_type = getattr(model, '__jsonapi_type_override__', prepped_name) model_keys = set(model.__mapper__.all_orm_descriptors.keys()) model_keys |= set(model.__mapper__.relationships.keys()) @@ -195,14 +236,8 @@ def __init__(self, base, prefix=''): model.__jsonapi_rel_desc__ = {} model.__jsonapi_permissions__ = {} model.__jsonapi_type__ = api_type - model.__jsonapi_map_to_py__ = { - dasherize(underscore(x)): x - for x in model_keys - } - model.__jsonapi_map_to_api__ = { - v: k - for k, v in model.__jsonapi_map_to_py__.items() - } + model.__jsonapi_map_to_py__ = {dasherize(underscore(x)): x for x in model_keys} + model.__jsonapi_map_to_api__ = {x: dasherize(underscore(x)) for x in model_keys} for prop_name, prop_value in iterate_attributes(model): @@ -257,10 +292,8 @@ def _fetch_model(self, api_type): def _lazy_relationship(self, api_type, obj_id, rel_key): return { - 'self': '{}/{}/{}/relationships/{}'.format(self.prefix, api_type, - obj_id, rel_key), - 'related': '{}/{}/{}/{}'.format(self.prefix, api_type, obj_id, - rel_key) + 'self': '{}/{}/{}/relationships/{}'.format(self.prefix, api_type, obj_id, rel_key), + 'related': '{}/{}/{}/{}'.format(self.prefix, api_type, obj_id, rel_key) } def _get_relationship(self, resource, rel_key, permission): @@ -326,20 +359,18 @@ def _render_full_resource(self, instance, include, fields): } attrs_to_ignore = {'__mapper__', 'id'} if api_type in fields.keys(): - local_fields = list(map(( - lambda x: instance.__jsonapi_map_to_py__.get(x)), fields.get( - api_type))) + local_fields = list(map((lambda x: instance.__jsonapi_map_to_py__[x]), fields[api_type])) else: local_fields = orm_desc_keys for key, relationship in instance.__mapper__.relationships.items(): - attrs_to_ignore |= set( - [c.name for c in relationship.local_columns]) | {key} + attrs_to_ignore |= set([c.name for c in relationship.local_columns + ]) | {key} api_key = instance.__jsonapi_map_to_api__[key] try: - desc = get_rel_desc(instance, key, Actions.GET) + desc = get_rel_desc(instance, key, RelationshipActions.GET) except PermissionDeniedError: continue @@ -353,29 +384,23 @@ def _render_full_resource(self, instance, include, fields): if api_key in include.keys(): related = desc(instance) if related is not None: - perm = get_permission_test(related, None, - Permissions.VIEW) - if key in local_fields and (related is None or - not perm(related)): + perm = get_permission_test(related, None, Permissions.VIEW) + if key in local_fields and (related is None or not perm(related)): to_ret['relationships'][api_key]['data'] = None continue if key in local_fields: - to_ret['relationships'][api_key][ - 'data'] = self._render_short_instance(related) + to_ret['relationships'][api_key]['data'] = self._render_short_instance(related) new_include = self._parse_include(include[api_key]) - built = self._render_full_resource(related, new_include, - fields) + built = self._render_full_resource(related, new_include, fields) included = built.pop('included') to_ret['included'].update(included) - to_ret['included'][(related.__jsonapi_type__, - related.id)] = built + to_ret['included'][(related.__jsonapi_type__, related.id)] = built else: if key in local_fields: to_ret['relationships'][api_key] = { - 'links': self._lazy_relationship(api_type, instance.id, - api_key), + 'links': self._lazy_relationship(api_type, instance.id, api_key), } if api_key not in include.keys(): @@ -393,23 +418,20 @@ def _render_full_resource(self, instance, include, fields): continue if key in local_fields: - to_ret['relationships'][api_key]['data'].append( - self._render_short_instance(item)) + to_ret['relationships'][api_key]['data'].append(self._render_short_instance(item)) new_include = self._parse_include(include[api_key]) built = self._render_full_resource(item, new_include, fields) included = built.pop('included') to_ret['included'].update(included) - to_ret['included'][(item.__jsonapi_type__, - item.id)] = built + to_ret['included'][(item.__jsonapi_type__, item.id)] = built for key in set(orm_desc_keys) - attrs_to_ignore: try: - desc = get_attr_desc(instance, key, Actions.GET) + desc = get_attr_desc(instance, key, AttributeActions.GET) if key in local_fields: - to_ret['attributes'][instance.__jsonapi_map_to_api__[ - key]] = desc(instance) + to_ret['attributes'][instance.__jsonapi_map_to_api__[key]] = desc(instance) except PermissionDeniedError: continue @@ -704,8 +726,8 @@ def get_related(self, session, query, api_type, obj_id, rel_key): for item in related: try: - response.data['data'].append(self._render_full_resource( - item, {}, {})) + response.data['data'].append( + self._render_full_resource(item, {}, {})) except PermissionDeniedError: continue @@ -734,7 +756,7 @@ def get_relationship(self, session, query, api_type, obj_id, rel_key): RelationshipActions.GET)(resource) if relationship.direction == MANYTOONE: - if related is None: + if related == None: response.data['data'] = None else: try: @@ -746,8 +768,8 @@ def get_relationship(self, session, query, api_type, obj_id, rel_key): response.data['data'] = [] for item in related: try: - response.data['data'].append(self._render_short_instance( - item)) + response.data['data'].append( + self._render_short_instance(item)) except PermissionDeniedError: continue @@ -779,7 +801,7 @@ def patch_relationship(self, session, json_data, api_type, obj_id, try: if relationship.direction == MANYTOONE: if not isinstance(json_data['data'], dict)\ - and json_data['data'] is not None: + and json_data['data'] != None: raise ValidationError('Provided data must be a hash.') related = getattr(resource, relationship.key) @@ -789,7 +811,7 @@ def patch_relationship(self, session, json_data, api_type, obj_id, setter = get_rel_desc(resource, relationship.key, RelationshipActions.SET) - if json_data['data'] is None: + if json_data['data'] == None: setter(resource, None) else: to_relate = self._fetch_resource( @@ -866,9 +888,10 @@ def patch_resource(self, session, json_data, api_type, obj_id): - set(resource.__jsonapi_map_to_py__.keys()) if missing_keys: - raise BadRequestError('{} not relationships for {}.{}'.format( - ', '.join(list(missing_keys)), model.__jsonapi_type__, - resource.id)) + raise BadRequestError( + '{} not relationships for {}.{}'.format( + ', '.join(list(missing_keys)), + model.__jsonapi_type__, resource.id)) attrs_to_ignore = {'__mapper__', 'id'} @@ -886,20 +909,18 @@ def patch_resource(self, session, json_data, api_type, obj_id): session, json_data['data']['relationships'][api_key], model.__jsonapi_type__, resource.id, api_key) - data_keys = set(map(( - lambda x: resource.__jsonapi_map_to_py__.get(x, None)), - json_data['data']['attributes'].keys())) + data_keys = set(map((lambda x: resource.__jsonapi_map_to_py__.get(x, None)), json_data['data']['attributes'].keys())) model_keys = set(orm_desc_keys) - attrs_to_ignore if not data_keys <= model_keys: - raise BadRequestError('{} not attributes for {}.{}'.format( - ', '.join(list(data_keys - model_keys)), - model.__jsonapi_type__, resource.id)) + raise BadRequestError( + '{} not attributes for {}.{}'.format( + ', '.join(list(data_keys - model_keys)), + model.__jsonapi_type__, resource.id)) for key in data_keys & model_keys: setter = get_attr_desc(resource, key, AttributeActions.SET) - setter(resource, json_data['data']['attributes'][ - resource.__jsonapi_map_to_api__[key]]) + setter(resource, json_data['data']['attributes'][resource.__jsonapi_map_to_api__[key]]) session.commit() except IntegrityError as e: session.rollback() @@ -910,8 +931,8 @@ def patch_resource(self, session, json_data, api_type, obj_id): except TypeError as e: session.rollback() raise ValidationError('Incompatible data type') - return self.get_resource(session, {}, model.__jsonapi_type__, - resource.id) + return self.get_resource( + session, {}, model.__jsonapi_type__, resource.id) def post_collection(self, session, data, api_type): """ @@ -930,8 +951,8 @@ def post_collection(self, session, data, api_type): raise MissingTypeError() if data['data']['type'] != model.__jsonapi_type__: - raise InvalidTypeForEndpointError(model.__jsonapi_type__, - data['data']['type']) + raise InvalidTypeForEndpointError( + model.__jsonapi_type__, data['data']['type']) resource = model() check_permission(resource, None, Permissions.CREATE) @@ -939,14 +960,13 @@ def post_collection(self, session, data, api_type): data['data'].setdefault('relationships', {}) data['data'].setdefault('attributes', {}) - data_keys = set(map(( - lambda x: resource.__jsonapi_map_to_py__.get(x, None)), data[ - 'data'].get('relationships', {}).keys())) + data_keys = set(map((lambda x: resource.__jsonapi_map_to_py__.get(x, None)), data['data'].get('relationships', {}).keys())) model_keys = set(resource.__mapper__.relationships.keys()) if not data_keys <= model_keys: - raise BadRequestError('{} not relationships for {}'.format( - ', '.join(list(data_keys - model_keys)), - model.__jsonapi_type__)) + raise BadRequestError( + '{} not relationships for {}'.format( + ', '.join(list(data_keys - + model_keys)), model.__jsonapi_type__)) attrs_to_ignore = {'__mapper__', 'id'} @@ -978,8 +998,8 @@ def post_collection(self, session, data, api_type): setters.append([setter, None]) else: if not isinstance(data_rel, dict): - raise BadRequestError('{} must be a hash'.format( - key)) + raise BadRequestError( + '{} must be a hash'.format(key)) if not {'type', 'id'} == set(data_rel.keys()): raise BadRequestError( '{} must have type and id keys'.format(key)) @@ -998,8 +1018,8 @@ def post_collection(self, session, data, api_type): setter = get_rel_desc(resource, key, RelationshipActions.APPEND) if not isinstance(data_rel, list): - raise BadRequestError('{} must be an array'.format( - key)) + raise BadRequestError( + '{} must be an array'.format(key)) for item in data_rel: if not {'type', 'id'} in set(item.keys()): raise BadRequestError( @@ -1016,15 +1036,14 @@ def post_collection(self, session, data, api_type): Permissions.CREATE) setters.append([setter, to_relate]) - data_keys = set(map(( - lambda x: resource.__jsonapi_map_to_py__.get(x, None)), data[ - 'data'].get('attributes', {}).keys())) + data_keys = set(map((lambda x: resource.__jsonapi_map_to_py__.get(x, None)), data['data'].get('attributes', {}).keys())) model_keys = set(orm_desc_keys) - attrs_to_ignore if not data_keys <= model_keys: - raise BadRequestError('{} not attributes for {}'.format( - ', '.join(list(data_keys - model_keys)), - model.__jsonapi_type__)) + raise BadRequestError( + '{} not attributes for {}'.format( + ', '.join(list(data_keys - + model_keys)), model.__jsonapi_type__)) with session.no_autoflush: for setter, value in setters: @@ -1048,8 +1067,8 @@ def post_collection(self, session, data, api_type): session.rollback() raise ValidationError('Incompatible data type') session.refresh(resource) - response = self.get_resource(session, {}, model.__jsonapi_type__, - resource.id) + response = self.get_resource( + session, {}, model.__jsonapi_type__, resource.id) response.status_code = 201 return response @@ -1085,13 +1104,14 @@ def post_relationship(self, session, json_data, api_type, obj_id, rel_key): RelationshipActions.APPEND) if not isinstance(json_data['data'], list): - raise BadRequestError('{} must be an array'.format( - relationship.key)) + raise BadRequestError( + '{} must be an array'.format(relationship.key)) for item in json_data['data']: if {'type', 'id'} != set(item.keys()): - raise BadRequestError('{} must have type and id keys' - .format(relationship.key)) + raise BadRequestError( + '{} must have type and id keys' + .format(relationship.key)) to_relate = self._fetch_resource( session, item['type'], item['id'], Permissions.EDIT) @@ -1114,5 +1134,5 @@ def post_relationship(self, session, json_data, api_type, obj_id, rel_key): except KeyError: raise ValidationError('Incompatible type provided') - return self.get_relationship(session, {}, model.__jsonapi_type__, - resource.id, rel_key) + return self.get_relationship( + session, {}, model.__jsonapi_type__, resource.id, rel_key) diff --git a/sqlalchemy_jsonapi/tests/app.py b/sqlalchemy_jsonapi/tests/app.py new file mode 100644 index 0000000..386fe75 --- /dev/null +++ b/sqlalchemy_jsonapi/tests/app.py @@ -0,0 +1,176 @@ +""" +SQLAlchemy JSONAPI Test App. + +Colton Provias +MIT License +""" + +from uuid import uuid4 + +from flask import Flask, request +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import Boolean, Column, ForeignKey, Unicode, UnicodeText +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import backref, relationship, validates +from sqlalchemy_jsonapi import FlaskJSONAPI, Permissions, permission_test, Method, Endpoint, INTERACTIVE_PERMISSIONS +from sqlalchemy_utils import EmailType, PasswordType, Timestamp, UUIDType + +app = Flask(__name__) + +app.testing = True + +db = SQLAlchemy(app) + +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' +app.config['SQLALCHEMY_ECHO'] = False + + +class User(Timestamp, db.Model): + """Quick and dirty user model.""" + + #: If __jsonapi_type__ is not provided, it will use the class name instead. + __tablename__ = 'users' + + id = Column(UUIDType, default=uuid4, primary_key=True) + username = Column(Unicode(30), unique=True, nullable=False) + email = Column(EmailType, nullable=False) + password = Column(PasswordType(schemes=['bcrypt']), + nullable=False, + info={'allow_serialize': False}) + is_admin = Column(Boolean, default=False) + + @hybrid_property + def total_comments(self): + """ + Total number of comments. + + Provides an example of a computed property. + """ + return self.comments.count() + + @validates('email') + def validate_email(self, key, email): + """Strong email validation.""" + assert '@' in email, 'Not an email' + return email + + @validates('username') + def validate_username(self, key, username): + """ + Check the length of the username. + + Here's hoping nobody submits something in unicode that is 31 characters + long!! + """ + assert len(username) >= 4 and len( + username) <= 30, 'Must be 4 to 30 characters long.' + return username + + @validates('password') + def validate_password(self, key, password): + """Validate a password's length.""" + assert len(password) >= 5, 'Password must be 5 characters or longer.' + return password + + @permission_test(Permissions.VIEW, 'password') + def view_password(self): + """ Never let the password be seen. """ + return False + + @permission_test(Permissions.EDIT) + def prevent_edit(self): + """ Prevent editing for no reason. """ + if request.view_args['api_type'] == 'blog-posts': + return True + return False + + @permission_test(Permissions.DELETE) + def allow_delete(self): + """ Just like a popular social media site, we won't delete users. """ + return False + + +class BlogPost(Timestamp, db.Model): + """Post model, as if this is a blog.""" + + __tablename__ = 'posts' + + id = Column(UUIDType, default=uuid4, primary_key=True) + title = Column(Unicode(100), nullable=False) + slug = Column(Unicode(100)) + content = Column(UnicodeText, nullable=False) + is_published = Column(Boolean, default=False) + author_id = Column(UUIDType, ForeignKey('users.id')) + + author = relationship('User', + lazy='joined', + backref=backref('posts', + lazy='dynamic')) + + @validates('title') + def validate_title(self, key, title): + """Keep titles from getting too long.""" + assert len(title) >= 5 or len( + title) <= 100, 'Must be 5 to 100 characters long.' + return title + + @permission_test(Permissions.VIEW) + def allow_view(self): + """ Hide unpublished. """ + return self.is_published + + @permission_test(INTERACTIVE_PERMISSIONS, 'logs') + def prevent_altering_of_logs(self): + return False + + +class BlogComment(Timestamp, db.Model): + """Comment for each Post.""" + + __tablename__ = 'comments' + + id = Column(UUIDType, default=uuid4, primary_key=True) + post_id = Column(UUIDType, ForeignKey('posts.id')) + author_id = Column(UUIDType, ForeignKey('users.id'), nullable=False) + content = Column(UnicodeText, nullable=False) + + post = relationship('BlogPost', + lazy='joined', + backref=backref('comments', + lazy='dynamic')) + author = relationship('User', + lazy='joined', + backref=backref('comments', + lazy='dynamic')) + + +class Log(Timestamp, db.Model): + __tablename__ = 'logs' + id = Column(UUIDType, default=uuid4, primary_key=True) + post_id = Column(UUIDType, ForeignKey('posts.id')) + user_id = Column(UUIDType, ForeignKey('users.id')) + + post = relationship('BlogPost', + lazy='joined', + backref=backref('logs', + lazy='dynamic')) + user = relationship('User', + lazy='joined', + backref=backref('logs', + lazy='dynamic')) + + @permission_test(INTERACTIVE_PERMISSIONS) + def block_interactive(cls): + return False + + +api = FlaskJSONAPI(app, db) + + +@api.wrap_handler(['blog-posts'], [Method.GET], [Endpoint.COLLECTION]) +def sample_override(next, *args, **kwargs): + return next(*args, **kwargs) + + +if __name__ == '__main__': + app.run() diff --git a/sqlalchemy_jsonapi/tests/conftest.py b/sqlalchemy_jsonapi/tests/conftest.py new file mode 100644 index 0000000..e028a30 --- /dev/null +++ b/sqlalchemy_jsonapi/tests/conftest.py @@ -0,0 +1,136 @@ +""" +SQLAlchemy-JSONAPI Testing Fixtures. + +Colton J. Provias +MIT License +""" + +import json + +import pytest +from flask import Response +from flask.testing import FlaskClient +from sqlalchemy.orm import sessionmaker +from app import db as db_ +from app import app, User, BlogPost, BlogComment, Log +from faker import Faker + +Session = sessionmaker() + +fake = Faker() + + +@pytest.yield_fixture(scope='session') +def flask_app(): + """Set up the application context for testing.""" + ctx = app.app_context() + ctx.push() + yield app + ctx.pop() + + +@pytest.yield_fixture(scope='session') +def db(flask_app): + """Set up the database as a session-wide fixture.""" + db_.app = flask_app + db_.drop_all() + db_.create_all() + yield db_ + + +@pytest.yield_fixture(scope='function') +def session(request, db): + """Create the transaction for each function so we don't rebuild.""" + connection = db.engine.connect() + transaction = connection.begin() + options = {'bind': connection} + session = db.create_scoped_session(options=options) + yield session + transaction.rollback() + connection.close() + session.remove() + + +class TestingResponse(Response): + def validate(self, status_code, error=None): + print(self.data) + assert self.status_code == status_code + assert self.headers['Content-Type'] == 'application/vnd.api+json' + if status_code != 204: + self.json_data = json.loads(self.data.decode()) + if error: + assert self.status_code == error.status_code + assert self.json_data['errors'][0]['code'] == error.code + assert self.json_data['errors'][0]['status' + ] == error.status_code + return self + + +@pytest.fixture +def client(flask_app): + """Set up the testing client.""" + with FlaskClient(flask_app, + use_cookies=True, + response_wrapper=TestingResponse) as c: + return c + + +@pytest.fixture +def user(session): + new_user = User(email=fake.email(), + password=fake.sentence(), + username=fake.user_name()) + session.add(new_user) + session.commit() + return new_user + + +@pytest.fixture +def post(user, session): + new_post = BlogPost(author=user, + title=fake.sentence(), + content=fake.paragraph(), + is_published=True) + session.add(new_post) + session.commit() + return new_post + + +@pytest.fixture +def unpublished_post(user, session): + new_post = BlogPost(author=user, + title=fake.sentence(), + content=fake.paragraph(), + is_published=False) + session.add(new_post) + session.commit() + return new_post + + +@pytest.fixture +def bunch_of_posts(user, session): + for x in range(30): + new_post = BlogPost(author=user, + title=fake.sentence(), + content=fake.paragraph(), + is_published=fake.boolean()) + session.add(new_post) + new_post.comments.append(BlogComment(author=user, + content=fake.paragraph())) + session.commit() + + +@pytest.fixture +def comment(user, post, session): + new_comment = BlogComment(author=user, post=post, content=fake.paragraph()) + session.add(new_comment) + session.commit() + return new_comment + + +@pytest.fixture +def log(user, post, session): + new_log = Log(user=user, post=post) + session.add(new_log) + session.commit() + return new_log diff --git a/tests/test_collection_get.py b/sqlalchemy_jsonapi/tests/test_collection_get.py similarity index 73% rename from tests/test_collection_get.py rename to sqlalchemy_jsonapi/tests/test_collection_get.py index 6a1bbed..8442eb8 100644 --- a/tests/test_collection_get.py +++ b/sqlalchemy_jsonapi/tests/test_collection_get.py @@ -1,34 +1,5 @@ -from schema import Schema -from jsonschema import validate - - -def test_200_with_no_querystring(): - pass - - -def describe_bad_query_params(): - def test_bad_query_param(): - pass - -def describe_resource_inclusions(): - pass - -def describe_sparse_fieldsets(): - pass - -def describe_sorting(): - pass - -def describe_pagination(): - pass - -def describe_filtering(): - pass - - -"""from sqlalchemy_jsonapi.errors import BadRequestError, NotSortableError - -# TODO: Vanilla +from sqlalchemy_jsonapi.errors import ( + BadRequestError, NotAnAttributeError, NotSortableError) def test_200_with_no_querystring(bunch_of_posts, client): @@ -36,10 +7,6 @@ def test_200_with_no_querystring(bunch_of_posts, client): assert response.json_data['data'][0]['type'] == 'blog-posts' assert response.json_data['data'][0]['id'] -# TODO: Bad Query Param - -# TODO: Resource Inclusions - def test_200_with_single_included_model(bunch_of_posts, client): response = client.get('/api/blog-posts/?include=author').validate(200) @@ -49,46 +16,31 @@ def test_200_with_single_included_model(bunch_of_posts, client): def test_200_with_including_model_and_including_inbetween(bunch_of_posts, client): - response = client.get('/api/blog-comments/?include=post.author').validate( - 200) + response = client.get('/api/blog-comments/?include=post.author').validate(200) assert response.json_data['data'][0]['type'] == 'blog-comments' for data in response.json_data['included']: assert data['type'] in ['blog-posts', 'users'] def test_200_with_multiple_includes(bunch_of_posts, client): - response = client.get('/api/blog-posts/?include=comments,author').validate( - 200) + response = client.get('/api/blog-posts/?include=comments,author').validate(200) assert response.json_data['data'][0]['type'] == 'blog-posts' for data in response.json_data['included']: assert data['type'] in ['blog-comments', 'users'] -# TODO: Sparse Fieldsets - def test_200_with_single_field(bunch_of_posts, client): - response = client.get( - '/api/blog-posts/?fields[blog-posts]=title').validate(200) + response = client.get('/api/blog-posts/?fields[blog-posts]=title').validate(200) for item in response.json_data['data']: assert {'title'} == set(item['attributes'].keys()) assert len(item['relationships']) == 0 -def test_200_with_bad_field(bunch_of_posts, client): - response = client.get( - '/api/blog-posts/?fields[blog-posts]=titles').validate(200) - for item in response.json_data['data']: - assert set() == set(item['attributes'].keys()) - assert len(item['relationships']) == 0 - - def test_200_with_multiple_fields(bunch_of_posts, client): - response = client.get( - '/api/blog-posts/?fields[blog-posts]=title,content,is-published').validate( - 200) + response = client.get('/api/blog-posts/?fields[blog-posts]=title,content,is-published').validate( + 200) for item in response.json_data['data']: - assert {'title', 'content', 'is-published'} == set(item[ - 'attributes'].keys()) + assert {'title', 'content', 'is-published'} == set(item['attributes'].keys()) assert len(item['relationships']) == 0 @@ -104,8 +56,6 @@ def test_200_with_single_field_across_a_relationship(bunch_of_posts, client): assert len(item['attributes']) == 0 assert {'author'} == set(item['relationships'].keys()) -# TODO: Sorting - def test_200_sorted_response(bunch_of_posts, client): response = client.get('/api/blog-posts/?sort=title').validate(200) @@ -133,29 +83,23 @@ def test_409_when_given_a_missing_field_for_sorting(bunch_of_posts, client): client.get('/api/blog-posts/?sort=never_gonna_give_you_up').validate( 409, NotSortableError) -# TODO: Pagination - def test_200_paginated_response_by_page(bunch_of_posts, client): - response = client.get( - '/api/blog-posts/?page[number]=2&page[size]=5').validate(200) + response = client.get('/api/blog-posts/?page[number]=2&page[size]=5').validate( + 200) assert len(response.json_data['data']) == 5 def test_200_paginated_response_by_offset(bunch_of_posts, client): - response = client.get( - '/api/blog-posts/?page[offset]=5&page[limit]=5').validate(200) + response = client.get('/api/blog-posts/?page[offset]=5&page[limit]=5').validate( + 200) assert len(response.json_data['data']) == 5 def test_200_when_pagination_is_out_of_range(bunch_of_posts, client): - client.get('/api/blog-posts/?page[offset]=999999&page[limit]=5').validate( - 200) + client.get('/api/blog-posts/?page[offset]=999999&page[limit]=5').validate(200) def test_400_when_provided_crap_data_for_pagination(bunch_of_posts, client): client.get('/api/blog-posts/?page[offset]=5&page[limit]=crap').validate( 400, BadRequestError) - -# TODO: Filtering -""" diff --git a/sqlalchemy_jsonapi/tests/test_collection_post.py b/sqlalchemy_jsonapi/tests/test_collection_post.py new file mode 100644 index 0000000..8dba8e2 --- /dev/null +++ b/sqlalchemy_jsonapi/tests/test_collection_post.py @@ -0,0 +1,150 @@ +import json + +from sqlalchemy_jsonapi.errors import ( + InvalidTypeForEndpointError, MissingTypeError, PermissionDeniedError, + ValidationError, MissingContentTypeError, NotAnAttributeError, + BadRequestError) +from faker import Faker + +fake = Faker() + + +def test_200_resource_creation(client): + payload = { + 'data': { + 'type': 'users', + 'attributes': { + 'username': fake.user_name(), + 'email': 'user@example.com', + 'password': 'password' + } + } + } + response = client.post('/api/users/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 201) + assert response.json_data['data']['type'] == 'users' + user_id = response.json_data['data']['id'] + response = client.get('/api/users/{}/'.format(user_id)).validate(200) + + +def test_200_resource_creation_with_relationships(user, client): + payload = { + 'data': { + 'type': 'blog-posts', + 'attributes': { + 'title': 'Some title', + 'content': 'Hello, World!', + 'is-published': True + }, + 'relationships': { + 'author': { + 'data': { + 'type': 'users', + 'id': str(user.id) + } + } + } + } + } + response = client.post('/api/blog-posts/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 201) + assert response.json_data['data']['type'] == 'blog-posts' + post_id = response.json_data['data']['id'] + response = client.get('/api/blog-posts/{}/?include=author'.format(post_id)).validate(200) + assert response.json_data['data']['relationships']['author']['data'][ + 'id' + ] == str(user.id) + + +def test_403_when_access_is_denied(client): + payload = {'data': {'type': 'logs'}} + client.post('/api/logs/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 403, PermissionDeniedError) + + +def test_409_when_id_already_exists(user, client): + payload = { + 'data': { + 'type': 'users', + 'id': str(user.id), + 'attributes': { + 'username': 'my_user', + 'email': 'user@example.com', + 'password': 'password' + } + } + } + client.post('/api/users/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_409_when_type_doesnt_match_endpoint(client): + payload = {'data': {'type': 'blog-posts'}} + client.post('/api/users/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, InvalidTypeForEndpointError) + + +def test_409_when_missing_content_type(client): + client.post('/api/users/', + data='{}').validate(409, MissingContentTypeError) + + +def test_409_when_missing_type(client): + payload = { + 'data': { + 'attributes': { + 'username': 'my_user', + 'email': 'user@example.com', + 'password': 'password' + } + } + } + client.post('/api/users/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, MissingTypeError) + + +def test_409_for_invalid_value(client): + payload = { + 'data': { + 'type': 'users', + 'attributes': { + 'username': 'my_user', + 'email': 'bad_email', + 'password': 'password' + } + } + } + client.post('/api/users/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_409_for_wrong_field_name(client): + payload = { + 'data': { + 'type': 'users', + 'attributes': { + 'username': 'my_user', + 'email': 'some@example.com', + 'password': 'password', + 'wrong_field': True + } + } + } + client.post('/api/users/', + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) diff --git a/tests/test_related_get.py b/sqlalchemy_jsonapi/tests/test_related_get.py similarity index 60% rename from tests/test_related_get.py rename to sqlalchemy_jsonapi/tests/test_related_get.py index cf16a6c..225d028 100644 --- a/tests/test_related_get.py +++ b/sqlalchemy_jsonapi/tests/test_related_get.py @@ -1,21 +1,12 @@ -"""from uuid import uuid4 +from uuid import uuid4 from sqlalchemy_jsonapi.errors import (RelationshipNotFoundError, ResourceNotFoundError) -# TODO: Sparse Fieldsets -# TODO: Related Includes -# TODO: Sorting -# TODO: Pagination -# TODO: Ember-style filtering -# TODO: Simple filtering -# TODO: Complex filtering -# TODO: Bad query param - def test_200_result_of_to_one(post, client): - response = client.get('/api/blog-posts/{}/author/'.format( - post.id)).validate(200) + response = client.get('/api/blog-posts/{}/author/'.format(post.id)).validate( + 200) assert response.json_data['data']['type'] == 'users' @@ -26,11 +17,10 @@ def test_200_collection_of_to_many(comment, client): def test_404_when_relationship_not_found(post, client): - client.get('/api/blog-posts/{}/last_comment/'.format(post.id)).validate( - 404, RelationshipNotFoundError) + client.get('/api/blog-posts/{}/last_comment/'.format( + post.id)).validate(404, RelationshipNotFoundError) def test_404_when_resource_not_found(client): client.get('/api/blog-posts/{}/comments/'.format(uuid4())).validate( 404, ResourceNotFoundError) -""" diff --git a/sqlalchemy_jsonapi/tests/test_relationship_delete.py b/sqlalchemy_jsonapi/tests/test_relationship_delete.py new file mode 100644 index 0000000..c7ae5a8 --- /dev/null +++ b/sqlalchemy_jsonapi/tests/test_relationship_delete.py @@ -0,0 +1,58 @@ +import json +from uuid import uuid4 + +from sqlalchemy_jsonapi.errors import ( + BadRequestError, PermissionDeniedError, RelationshipNotFoundError, + ResourceNotFoundError, ToManyExpectedError, MissingContentTypeError, + ValidationError) + + +def test_200_on_deletion_from_to_many(comment, client): + payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} + response = client.delete( + '/api/blog-posts/{}/relationships/comments/'.format( + comment.post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate(200) + for item in response.json_data['data']: + assert {'id', 'type'} == set(item.keys()) + assert payload['data'][0]['id'] not in [str(x['id']) + for x in response.json_data['data'] + ] + + +def test_404_on_resource_not_found(client): + client.delete('/api/blog-posts/{}/relationships/comments/'.format(uuid4()), + data='{}', + content_type='application/vnd.api+json').validate( + 404, ResourceNotFoundError) + + +def test_404_on_relationship_not_found(post, client): + client.delete('/api/blog-posts/{}/relationships/comment/'.format( + post.id), + data='{}', + content_type='application/vnd.api+json').validate( + 404, RelationshipNotFoundError) + + +def test_403_on_permission_denied(user, client): + client.delete('/api/users/{}/relationships/logs/'.format( + user.id), + data='{"data": []}', + content_type='application/vnd.api+json').validate( + 403, PermissionDeniedError) + + +def test_409_on_to_one_provided(post, client): + client.delete('/api/blog-posts/{}/relationships/author/'.format( + post.id), + data='{"data": {}}', + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_409_missing_content_type_header(post, client): + client.delete('/api/blog-posts/{}/relationships/comment/'.format( + post.id), + data='{}').validate(409, MissingContentTypeError) diff --git a/sqlalchemy_jsonapi/tests/test_relationship_get.py b/sqlalchemy_jsonapi/tests/test_relationship_get.py new file mode 100644 index 0000000..761784c --- /dev/null +++ b/sqlalchemy_jsonapi/tests/test_relationship_get.py @@ -0,0 +1,36 @@ +from sqlalchemy_jsonapi.errors import ( + RelationshipNotFoundError, ResourceNotFoundError, PermissionDeniedError) +from uuid import uuid4 + + +def test_200_on_to_many(post, client): + response = client.get( + '/api/blog-posts/{}/relationships/comments/'.format( + post.id)).validate(200) + for item in response.json_data['data']: + assert {'id', 'type'} == set(item.keys()) + + +def test_200_on_to_one(post, client): + response = client.get( + '/api/blog-posts/{}/relationships/author/'.format( + post.id)).validate(200) + assert response.json_data['data']['type'] == 'users' + + +def test_404_on_resource_not_found(client): + client.get( + '/api/blog-posts/{}/relationships/comments/'.format(uuid4())).validate( + 404, ResourceNotFoundError) + + +def test_404_on_relationship_not_found(post, client): + client.get( + '/api/blog-posts/{}/relationships/comment/'.format( + post.id)).validate(404, RelationshipNotFoundError) + + +def test_403_on_permission_denied(unpublished_post, client): + client.get( + '/api/blog-posts/{}/relationships/comment/'.format( + unpublished_post.id)).validate(403, PermissionDeniedError) diff --git a/sqlalchemy_jsonapi/tests/test_relationship_patch.py b/sqlalchemy_jsonapi/tests/test_relationship_patch.py new file mode 100644 index 0000000..d1d8df5 --- /dev/null +++ b/sqlalchemy_jsonapi/tests/test_relationship_patch.py @@ -0,0 +1,124 @@ +import json +from uuid import uuid4 + +from sqlalchemy_jsonapi.errors import (PermissionDeniedError, + RelationshipNotFoundError, + ResourceNotFoundError, ValidationError) + + +def test_200_on_to_one_set_to_resource(post, user, client): + payload = {'data': {'type': 'users', 'id': str(user.id)}} + response = client.patch( + '/api/blog-posts/{}/relationships/author/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate(200) + assert response.json_data['data']['id'] == str(user.id) + + +def test_200_on_to_one_set_to_null(post, client): + payload = {'data': None} + response = client.patch( + '/api/blog-posts/{}/relationships/author/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate(200) + assert response.json_data['data'] == None + + +def test_200_on_to_many_set_to_resources(post, comment, client): + payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} + response = client.patch('/api/blog-posts/{}/relationships/comments/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 200) + assert response.json_data['data'][0]['id'] == str(comment.id) + assert len(response.json_data['data']) == 1 + + +def test_200_on_to_many_set_to_empty(post, client): + payload = {'data': []} + response = client.patch('/api/blog-posts/{}/relationships/comments/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 200) + assert len(response.json_data['data']) == 0 + + +def test_409_on_to_one_set_to_empty_list(post, client): + payload = {'data': []} + client.patch('/api/blog-posts/{}/relationships/author/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_409_on_to_many_set_to_null(post, client): + payload = {'data': None} + client.patch('/api/blog-posts/{}/relationships/comments/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_404_on_resource_not_found(client): + client.patch('/api/blog-posts/{}/relationships/comments/'.format( + uuid4()), + data='{}', + content_type='application/vnd.api+json').validate( + 404, ResourceNotFoundError) + + +def test_404_on_relationship_not_found(client, post): + client.patch('/api/blog-posts/{}/relationships/comment/'.format( + post.id), + data='{}', + content_type='application/vnd.api+json').validate( + 404, RelationshipNotFoundError) + + +def test_404_on_related_item_not_found(post, client): + payload = {'data': [{'type': 'blog-comments', 'id': str(uuid4())}]} + client.patch('/api/blog-posts/{}/relationships/comments/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 404, ResourceNotFoundError) + + +def test_403_on_permission_denied(user, log, client): + payload = {'data': {'type': 'users', 'id': str(user.id)}} + client.patch('/api/logs/{}/relationships/user/'.format( + log.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 403, PermissionDeniedError) + + +def test_403_on_permission_denied_on_related(log, user, client): + payload = {'data': {'type': 'logs', 'id': str(log.id)}} + client.patch('/api/users/{}/relationships/logs/'.format( + user.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 403, PermissionDeniedError) + + +def test_409_on_to_one_with_incompatible_model(post, comment, client): + payload = {'data': {'type': 'blog-comments', 'id': str(comment.id)}} + client.patch('/api/blog-posts/{}/relationships/author/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_409_on_to_many_with_incompatible_model(post, client): + payload = {'data': [{'type': 'blog-posts', 'id': str(post.id)}]} + client.patch('/api/blog-posts/{}/relationships/author/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) diff --git a/sqlalchemy_jsonapi/tests/test_relationship_post.py b/sqlalchemy_jsonapi/tests/test_relationship_post.py new file mode 100644 index 0000000..287fedf --- /dev/null +++ b/sqlalchemy_jsonapi/tests/test_relationship_post.py @@ -0,0 +1,55 @@ +import json +from uuid import uuid4 + +from sqlalchemy_jsonapi.errors import ValidationError, ResourceNotFoundError, RelationshipNotFoundError + + +def test_200_on_to_many(comment, post, client): + payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} + response = client.post('/api/blog-posts/{}/relationships/comments/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 200) + assert str(comment.id) in [str(x['id']) + for x in response.json_data['data']] + + +def test_409_on_hash_instead_of_array_provided(comment, post, client): + payload = {'data': {'type': 'blog-comments', 'id': str(comment.id)}} + client.post('/api/blog-posts/{}/relationships/comments/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_409_on_incompatible_model(user, post, client): + payload = {'data': [{'type': 'users', 'id': str(user.id)}]} + client.post('/api/blog-posts/{}/relationships/comments/'.format( + post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) + + +def test_409_on_to_one_relationship(post, client): + client.post( + '/api/blog-posts/{}/relationships/author/'.format(post.id), + data='{}', + content_type='application/vnd.api+json').validate(409, ValidationError) + + +def test_404_on_resource_not_found(client): + client.post('/api/blog-posts/{}/relationships/comments/'.format(uuid4()), + data='{}', + content_type='application/vnd.api+json').validate( + 404, ResourceNotFoundError) + + +def test_404_on_relationship_not_found(post, client): + client.post('/api/blog-posts/{}/relationships/comment/'.format( + post.id), + data='{}', + content_type='application/vnd.api+json').validate( + 404, RelationshipNotFoundError) diff --git a/tests/test_resource_delete.py b/sqlalchemy_jsonapi/tests/test_resource_delete.py similarity index 93% rename from tests/test_resource_delete.py rename to sqlalchemy_jsonapi/tests/test_resource_delete.py index ffc6f1b..a48d429 100644 --- a/tests/test_resource_delete.py +++ b/sqlalchemy_jsonapi/tests/test_resource_delete.py @@ -1,10 +1,8 @@ -"""from uuid import uuid4 +from uuid import uuid4 from sqlalchemy_jsonapi.errors import ( PermissionDeniedError, ResourceNotFoundError, ResourceTypeNotFoundError) -# TODO: Bad query param - def test_200_on_success(comment, client): client.delete('/api/blog-comments/{}/'.format(comment.id)).validate(204) @@ -25,4 +23,3 @@ def test_403_on_permission_denied(user, client): def test_404_on_resource_not_found(client): client.delete('/api/blog-comments/{}/'.format(uuid4())).validate( 404, ResourceNotFoundError) -""" diff --git a/tests/test_resource_get.py b/sqlalchemy_jsonapi/tests/test_resource_get.py similarity index 84% rename from tests/test_resource_get.py rename to sqlalchemy_jsonapi/tests/test_resource_get.py index 3d680b3..4f81414 100644 --- a/tests/test_resource_get.py +++ b/sqlalchemy_jsonapi/tests/test_resource_get.py @@ -1,10 +1,6 @@ -"""from sqlalchemy_jsonapi.errors import ResourceNotFoundError, PermissionDeniedError +from sqlalchemy_jsonapi.errors import ResourceNotFoundError, PermissionDeniedError from uuid import uuid4 -# TODO: Sparse Fieldsets -# TODO: Related Includes -# TODO: Bad query param - def test_200_without_querystring(post, client): response = client.get('/api/blog-posts/{}/'.format(post.id)).validate(200) @@ -46,17 +42,16 @@ def test_200_with_multiple_includes(post, client): def test_200_with_single_field(post, client): - response = client.get( - '/api/blog-posts/{}/?fields[blog-posts]=title'.format( - post.id)).validate(200) + response = client.get('/api/blog-posts/{}/?fields[blog-posts]=title'.format( + post.id)).validate(200) assert {'title'} == set(response.json_data['data']['attributes'].keys()) assert len(response.json_data['data']['relationships']) == 0 def test_200_with_multiple_fields(post, client): - response = client.get( - '/api/blog-posts/{}/?fields[blog-posts]=title,content'.format( - post.id)).validate(200) + response = client.get('/api/blog-posts/{}/?fields[blog-posts]=title,content'.format( + post.id)).validate( + 200) assert {'title', 'content' } == set(response.json_data['data']['attributes'].keys()) assert len(response.json_data['data']['relationships']) == 0 @@ -65,7 +60,8 @@ def test_200_with_multiple_fields(post, client): def test_200_with_single_field_across_a_relationship(post, client): response = client.get( '/api/blog-posts/{}/?fields[blog-posts]=title,content&fields[blog-comments]=author&include=comments'.format( - post.id)).validate(200) + post.id)).validate( + 200) assert {'title', 'content' } == set(response.json_data['data']['attributes'].keys()) assert len(response.json_data['data']['relationships']) == 0 @@ -73,4 +69,3 @@ def test_200_with_single_field_across_a_relationship(post, client): assert {'title', 'content'} == set(item['attributes'].keys()) assert len(item['attributes']) == 0 assert {'author'} == set(item['relationships'].keys()) -""" diff --git a/tests/test_resource_patch.py b/sqlalchemy_jsonapi/tests/test_resource_patch.py similarity index 57% rename from tests/test_resource_patch.py rename to sqlalchemy_jsonapi/tests/test_resource_patch.py index c10aadf..e5cdac1 100644 --- a/tests/test_resource_patch.py +++ b/sqlalchemy_jsonapi/tests/test_resource_patch.py @@ -1,12 +1,10 @@ -"""import json +import json from uuid import uuid4 -from sqlalchemy_jsonapi.errors import (BadRequestError, PermissionDeniedError, - ResourceNotFoundError, ValidationError) - -# TODO: Sparse Fieldsets -# TODO: Related Includes -# TODO: Bad query param +from sqlalchemy_jsonapi.errors import ( + BadRequestError, PermissionDeniedError, ResourceNotFoundError, + RelatedResourceNotFoundError, RelationshipNotFoundError, ValidationError, + MissingTypeError) def test_200(client, post, user): @@ -27,28 +25,28 @@ def test_200(client, post, user): } } } - response = client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(200) + response = client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 200) assert response.json_data['data']['id'] == str(post.id) assert response.json_data['data']['type'] == 'blog-posts' - assert response.json_data['data']['attributes'][ - 'title'] == 'I just lost the game' + assert response.json_data['data']['attributes']['title' + ] == 'I just lost the game' def test_400_missing_type(post, client): - client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps({}), - content_type='application/vnd.api+json').validate(400, BadRequestError) + client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps({}), + content_type='application/vnd.api+json').validate( + 400, BadRequestError) def test_404_resource_not_found(client): - client.patch( - '/api/blog-posts/{}/'.format(uuid4()), - content_type='application/vnd.api+json', - data='{}').validate(404, ResourceNotFoundError) + client.patch('/api/blog-posts/{}/'.format(uuid4()), + content_type='application/vnd.api+json', + data='{}').validate( + 404, ResourceNotFoundError) def test_404_related_resource_not_found(client, post): @@ -66,11 +64,10 @@ def test_404_related_resource_not_found(client, post): } } } - client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate( - 404, ResourceNotFoundError) + client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 404, ResourceNotFoundError) def test_400_field_not_found(client, post, user): @@ -88,10 +85,10 @@ def test_400_field_not_found(client, post, user): } } } - client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(400, BadRequestError) + client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 400, BadRequestError) def test_409_type_mismatch_to_one(client, post, user): @@ -109,10 +106,10 @@ def test_409_type_mismatch_to_one(client, post, user): } } } - client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) + client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) def test_400_type_mismatch_to_many(client, post, user): @@ -130,10 +127,10 @@ def test_400_type_mismatch_to_many(client, post, user): } } } - client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(400, BadRequestError) + client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 400, BadRequestError) def test_409_validation_failed(client, post, user): @@ -154,10 +151,10 @@ def test_409_validation_failed(client, post, user): } } } - client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) + client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 409, ValidationError) def test_400_type_does_not_match_endpoint(client, post, user): @@ -178,16 +175,14 @@ def test_400_type_does_not_match_endpoint(client, post, user): } } } - client.patch( - '/api/blog-posts/{}/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(400, BadRequestError) + client.patch('/api/blog-posts/{}/'.format(post.id), + data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 400, BadRequestError) def test_403_permission_denied(user, client): - client.patch( - '/api/users/{}/'.format(user.id), - data='{}', - content_type='application/vnd.api+json').validate( - 403, PermissionDeniedError) -""" + client.patch('/api/users/{}/'.format(user.id), + data='{}', + content_type='application/vnd.api+json').validate( + 403, PermissionDeniedError) diff --git a/tests/test_serializer.py b/sqlalchemy_jsonapi/tests/test_serializer.py similarity index 75% rename from tests/test_serializer.py rename to sqlalchemy_jsonapi/tests/test_serializer.py index 18a4506..07060c9 100644 --- a/tests/test_serializer.py +++ b/sqlalchemy_jsonapi/tests/test_serializer.py @@ -1,4 +1,4 @@ -"""from app import api +from app import api import uuid @@ -7,7 +7,5 @@ def test_include_different_types_same_id(session, comment): comment.post.id = comment.author.id = comment.post_id = comment.author_id = new_id session.commit() - r = api.serializer.get_resource(session, {'include': 'post,author'}, - 'blog-comments', comment.id) + r = api.serializer.get_resource(session, {'include': 'post,author'}, 'blog-comments', comment.id) assert len(r.data['included']) == 2 -""" diff --git a/test.png b/test.png deleted file mode 100644 index ba872e2..0000000 Binary files a/test.png and /dev/null differ diff --git a/tests/app.py b/tests/app.py deleted file mode 100644 index b45507c..0000000 --- a/tests/app.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -SQLAlchemy JSONAPI Test App. - -This app implements the backend of a web forum. It's rather simple but -provides us with a more comprehensive example for testing. - -Colton Provias -MIT License -""" - -from uuid import uuid4 - -from flask import Flask, request -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import Boolean, Column, Enum, ForeignKey, Unicode, UnicodeText -from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import backref, relationship, validates -from sqlalchemy_utils import (EmailType, IPAddressType, PasswordType, - Timestamp, URLType, UUIDType) - -import enum - -# ================================ APP CONFIG ================================ - -app = Flask(__name__) - -app.testing = True - -db = SQLAlchemy(app) - -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' -app.config['SQLALCHEMY_ECHO'] = False - -#api = FlaskJSONAPI(app, db) - -# ================================== MODELS ================================== - - -class User(db.Model, Timestamp): - __tablename__ = 'users' - - id = Column(UUIDType, default=uuid4, primary_key=True, nullable=False) - email = Column(EmailType, nullable=False) - display_name = Column(Unicode(100), nullable=False) - password = Column(PasswordType(schemes=['bcrypt']), nullable=False) - is_admin = Column(Boolean, default=False) - last_ip_address = Column(IPAddressType) - website = Column(URLType) - - @validates('email') - def validate_email(self, key, email): - """Strong email validation.""" - assert '@' in email, 'Not an email' - return email - - -class Forum(db.Model, Timestamp): - __tablename__ = 'forums' - - id = Column(UUIDType, default=uuid4, primary_key=True, nullable=False) - name = Column(Unicode(255), nullable=False) - can_public_read = Column(Boolean, default=True) - can_public_write = Column(Boolean, default=True) - - -class Thread(db.Model, Timestamp): - __tablename__ = 'threads' - - id = Column(UUIDType, default=uuid4, primary_key=True, nullable=False) - forum_id = Column(UUIDType, ForeignKey('forums.id'), nullable=False) - started_by_id = Column(UUIDType, ForeignKey('users.id'), nullable=False) - title = Column(Unicode(255), nullable=False) - - -class Post(db.Model, Timestamp): - __tablename__ = 'posts' - - id = Column(UUIDType, default=uuid4, primary_key=True, nullable=False) - user_id = Column(UUIDType, ForeignKey('posts.id'), nullable=False) - content = Column(UnicodeText, nullable=False) - is_removed = Column(Boolean, default=False) - - -class ReportTypes(enum.Enum): - USER = 0 - POST = 1 - - -class Report(db.Model, Timestamp): - __tablename__ = 'reports' - - id = Column(UUIDType, default=uuid4, primary_key=True, nullable=False) - report_type = Column(Enum(ReportTypes), nullable=False) - reporter_id = Column(UUIDType, ForeignKey('users.id'), nullable=False) - complaint = Column(UnicodeText, nullable=False) - - __mapper_args__ = { - 'polymorphic_identity': 'employee', - 'polymorphic_on': report_type, - 'with_polymorphic': '*' - } - - -class UserReport(db.Model): - __tablename__ = 'user_reports' - - id = Column( - UUIDType, - ForeignKey('reports.id'), - default=uuid4, - primary_key=True, - nullable=False) - user_id = Column(UUIDType, ForeignKey('users.id'), nullable=False) - - __mapper_args__ = {'polymorphic_identity': ReportTypes.USER} - - -class PostReport(db.Model): - __tablename__ = 'post_reports' - - id = Column( - UUIDType, - ForeignKey('reports.id'), - default=uuid4, - primary_key=True, - nullable=False) - post_id = Column(UUIDType, ForeignKey('posts.id'), nullable=False) - - __mapper_args__ = {'polymorphic_identity': ReportTypes.POST} - -# ============================== EVENT HANDLERS ============================== - - -@app.before_request -def handle_auth(): - pass - -# ============================== API OVERRIDES ============================== - -#@api.wrap_handler(['blog-posts'], [Method.GET], [Endpoint.COLLECTION]) -#def sample_override(next, *args, **kwargs): -# return next(*args, **kwargs) - -# ================================ APP RUNNER ================================ - -if __name__ == '__main__': - app.run() diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 67c026c..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -SQLAlchemy-JSONAPI Testing Fixtures. - -Colton J. Provias -MIT License -""" - -import json - -import jsonschema -import pytest -from addict import Dict -from faker import Faker -from flask import Response -from flask.testing import FlaskClient -from sqlalchemy.orm import sessionmaker - -from app import db as db_ -from app import app - -Session = sessionmaker() - -fake = Faker() - -with open('tests/jsonapi_schema.json', 'r') as f: - api_schema = json.load(f) - - -@pytest.yield_fixture(scope='session') -def flask_app(): - """Set up the application context for testing.""" - ctx = app.app_context() - ctx.push() - yield app - ctx.pop() - - -@pytest.yield_fixture(scope='session') -def db(flask_app): - """Set up the database as a session-wide fixture.""" - db_.app = flask_app - db_.drop_all() - db_.create_all() - yield db_ - - -@pytest.yield_fixture(scope='function') -def session(request, db): - """Create the transaction for each function so we don't rebuild.""" - connection = db.engine.connect() - transaction = connection.begin() - options = {'bind': connection} - session = db.create_scoped_session(options=options) - yield session - transaction.rollback() - connection.close() - session.remove() - - -class TestingResponse(Response): - def validate(self, status_code, error=None): - print(self.data) - assert self.status_code == status_code - assert self.headers['Content-Type'] == 'application/vnd.api+json' - if status_code != 204: - json_data = json.loads(self.data.decode()) - jsonschema.validate(json_data, api_schema) - self.json_data = Dict(json_data) - if error: - assert self.status_code == error.status_code - assert self.json_data['errors'][0]['code'] == error.code - assert self.json_data['errors'][0][ - 'status'] == error.status_code - return self - - -@pytest.fixture -def client(flask_app): - """Set up the testing client.""" - with FlaskClient( - flask_app, use_cookies=True, - response_wrapper=TestingResponse) as c: - return c diff --git a/tests/jsonapi_schema.json b/tests/jsonapi_schema.json deleted file mode 100644 index 32132bc..0000000 --- a/tests/jsonapi_schema.json +++ /dev/null @@ -1,383 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "JSON API Schema", - "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org", - "oneOf": [ - { - "$ref": "#/definitions/success" - }, - { - "$ref": "#/definitions/failure" - }, - { - "$ref": "#/definitions/info" - } - ], - "definitions": { - "success": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "$ref": "#/definitions/data" - }, - "included": { - "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", - "type": "array", - "items": { - "$ref": "#/definitions/resource" - }, - "uniqueItems": true - }, - "meta": { - "$ref": "#/definitions/meta" - }, - "links": { - "description": "Link members related to the primary data.", - "allOf": [ - { - "$ref": "#/definitions/links" - }, - { - "$ref": "#/definitions/pagination" - } - ] - }, - "jsonapi": { - "$ref": "#/definitions/jsonapi" - } - }, - "additionalProperties": false - }, - "failure": { - "type": "object", - "required": [ - "errors" - ], - "properties": { - "errors": { - "type": "array", - "items": { - "$ref": "#/definitions/error" - }, - "uniqueItems": true - }, - "meta": { - "$ref": "#/definitions/meta" - }, - "jsonapi": { - "$ref": "#/definitions/jsonapi" - } - }, - "additionalProperties": false - }, - "info": { - "type": "object", - "required": [ - "meta" - ], - "properties": { - "meta": { - "$ref": "#/definitions/meta" - }, - "links": { - "$ref": "#/definitions/links" - }, - "jsonapi": { - "$ref": "#/definitions/jsonapi" - } - }, - "additionalProperties": false - }, - "meta": { - "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", - "type": "object", - "additionalProperties": true - }, - "data": { - "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", - "oneOf": [ - { - "$ref": "#/definitions/resource" - }, - { - "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", - "type": "array", - "items": { - "$ref": "#/definitions/resource" - }, - "uniqueItems": true - }, - { - "description": "null if the request is one that might correspond to a single resource, but doesn't currently.", - "type": "null" - } - ] - }, - "resource": { - "description": "\"Resource objects\" appear in a JSON API document to represent resources.", - "type": "object", - "required": [ - "type", - "id" - ], - "properties": { - "type": { - "type": "string" - }, - "id": { - "type": "string" - }, - "attributes": { - "$ref": "#/definitions/attributes" - }, - "relationships": { - "$ref": "#/definitions/relationships" - }, - "links": { - "$ref": "#/definitions/links" - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - }, - "links": { - "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", - "type": "object", - "properties": { - "self": { - "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", - "type": "string", - "format": "uri" - }, - "related": { - "$ref": "#/definitions/link" - } - }, - "additionalProperties": true - }, - "link": { - "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", - "oneOf": [ - { - "description": "A string containing the link's URL.", - "type": "string", - "format": "uri" - }, - { - "type": "object", - "required": [ - "href" - ], - "properties": { - "href": { - "description": "A string containing the link's URL.", - "type": "string", - "format": "uri" - }, - "meta": { - "$ref": "#/definitions/meta" - } - } - } - ] - }, - "attributes": { - "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", - "type": "object", - "patternProperties": { - "^(?!relationships$|links$)\\w[-\\w_]*$": { - "description": "Attributes may contain any valid JSON value." - } - }, - "additionalProperties": false - }, - "relationships": { - "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", - "type": "object", - "patternProperties": { - "^\\w[-\\w_]*$": { - "properties": { - "links": { - "$ref": "#/definitions/links" - }, - "data": { - "description": "Member, whose value represents \"resource linkage\".", - "oneOf": [ - { - "$ref": "#/definitions/relationshipToOne" - }, - { - "$ref": "#/definitions/relationshipToMany" - } - ] - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "relationshipToOne": { - "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", - "anyOf": [ - { - "$ref": "#/definitions/empty" - }, - { - "$ref": "#/definitions/linkage" - } - ] - }, - "relationshipToMany": { - "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", - "type": "array", - "items": { - "$ref": "#/definitions/linkage" - }, - "uniqueItems": true - }, - "empty": { - "description": "Describes an empty to-one relationship.", - "type": "null" - }, - "linkage": { - "description": "The \"type\" and \"id\" to non-empty members.", - "type": "object", - "required": [ - "type", - "id" - ], - "properties": { - "type": { - "type": "string" - }, - "id": { - "type": "string" - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - }, - "pagination": { - "type": "object", - "properties": { - "first": { - "description": "The first page of data", - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "null" - } - ] - }, - "last": { - "description": "The last page of data", - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "null" - } - ] - }, - "prev": { - "description": "The previous page of data", - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "null" - } - ] - }, - "next": { - "description": "The next page of data", - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "null" - } - ] - } - } - }, - "jsonapi": { - "description": "An object describing the server's implementation", - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - }, - "error": { - "type": "object", - "properties": { - "id": { - "description": "A unique identifier for this particular occurrence of the problem.", - "type": "string" - }, - "links": { - "$ref": "#/definitions/links" - }, - "status": { - "description": "The HTTP status code applicable to this problem, expressed as a string value.", - "type": "string" - }, - "code": { - "description": "An application-specific error code, expressed as a string value.", - "type": "string" - }, - "title": { - "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", - "type": "string" - }, - "detail": { - "description": "A human-readable explanation specific to this occurrence of the problem.", - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "pointer": { - "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", - "type": "string" - }, - "parameter": { - "description": "A string indicating which query parameter caused the error.", - "type": "string" - } - } - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - } - } -} \ No newline at end of file diff --git a/tests/test_collection_post.py b/tests/test_collection_post.py deleted file mode 100644 index df30f27..0000000 --- a/tests/test_collection_post.py +++ /dev/null @@ -1,210 +0,0 @@ -"""import json - -from sqlalchemy_jsonapi.errors import ( - InvalidTypeForEndpointError, MissingTypeError, PermissionDeniedError, - ValidationError, MissingContentTypeError, NotAnAttributeError, - BadRequestError) -from faker import Faker - -fake = Faker() - -# TODO: Bad query param - - -def test_200_resource_creation(client): - payload = { - 'data': { - 'type': 'users', - 'attributes': { - 'username': fake.user_name(), - 'email': 'user@example.com', - 'password': 'password' - } - } - } - response = client.post( - '/api/users/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(201) - assert response.json_data['data']['type'] == 'users' - user_id = response.json_data['data']['id'] - response = client.get('/api/users/{}/'.format(user_id)).validate(200) - - -def test_200_resource_creation_with_relationships(user, client): - payload = { - 'data': { - 'type': 'blog-posts', - 'attributes': { - 'title': 'Some title', - 'content': 'Hello, World!', - 'is-published': True - }, - 'relationships': { - 'author': { - 'data': { - 'type': 'users', - 'id': str(user.id) - } - } - } - } - } - response = client.post( - '/api/blog-posts/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(201) - assert response.json_data['data']['type'] == 'blog-posts' - assert len(response.json_data['included']) == 0 - post_id = response.json_data['data']['id'] - response = client.get('/api/blog-posts/{}/'.format(post_id)).validate(200) - - -def test_200_resource_creation_with_relationships_and_include(user, client): - payload = { - 'data': { - 'type': 'blog-posts', - 'attributes': { - 'title': 'Some title', - 'content': 'Hello, World!', - 'is-published': True - }, - 'relationships': { - 'author': { - 'data': { - 'type': 'users', - 'id': str(user.id) - } - } - } - } - } - response = client.post( - '/api/blog-posts/?include=author', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(201) - assert response.json_data['data']['type'] == 'blog-posts' - assert response.json_data['data']['relationships']['author']['data'][ - 'id'] == str(user.id) - assert len(response.json_data['included']) == 1 - for data in response.json_data['included']: - assert data['type'] == 'users' - post_id = response.json_data['data']['id'] - response = client.get('/api/blog-posts/{}/?include=author'.format( - post_id)).validate(200) - - -def test_200_resource_creation_with_sparse_fieldset(client): - payload = { - 'data': { - 'type': 'users', - 'attributes': { - 'username': fake.user_name(), - 'email': 'user@example.com', - 'password': 'password' - } - } - } - response = client.post( - '/api/users/?fields[users]=username', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(201) - assert response.json_data['data']['type'] == 'users' - assert set(response.json_data['data']['attributes'].keys()) == set( - ['username']) - user_id = response.json_data['data']['id'] - response = client.get('/api/users/{}/'.format(user_id)).validate(200) - - -def test_403_when_access_is_denied(client): - payload = {'data': {'type': 'logs'}} - client.post( - '/api/logs/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate( - 403, PermissionDeniedError) - - -def test_409_when_id_already_exists(user, client): - payload = { - 'data': { - 'type': 'users', - 'id': str(user.id), - 'attributes': { - 'username': 'my_user', - 'email': 'user@example.com', - 'password': 'password' - } - } - } - client.post( - '/api/users/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_409_when_type_doesnt_match_endpoint(client): - payload = {'data': {'type': 'blog-posts'}} - client.post( - '/api/users/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate( - 409, InvalidTypeForEndpointError) - - -def test_409_when_missing_content_type(client): - client.post( - '/api/users/', data='{}').validate(409, MissingContentTypeError) - - -def test_409_when_missing_type(client): - payload = { - 'data': { - 'attributes': { - 'username': 'my_user', - 'email': 'user@example.com', - 'password': 'password' - } - } - } - client.post( - '/api/users/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, - MissingTypeError) - - -def test_409_for_invalid_value(client): - payload = { - 'data': { - 'type': 'users', - 'attributes': { - 'username': 'my_user', - 'email': 'bad_email', - 'password': 'password' - } - } - } - client.post( - '/api/users/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_409_for_wrong_field_name(client): - payload = { - 'data': { - 'type': 'users', - 'attributes': { - 'username': 'my_user', - 'email': 'some@example.com', - 'password': 'password', - 'wrong_field': True - } - } - } - client.post( - '/api/users/', - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) -""" diff --git a/tests/test_relationship_delete.py b/tests/test_relationship_delete.py deleted file mode 100644 index f3d0d13..0000000 --- a/tests/test_relationship_delete.py +++ /dev/null @@ -1,65 +0,0 @@ -"""import json -from uuid import uuid4 - -from sqlalchemy_jsonapi.errors import ( - BadRequestError, PermissionDeniedError, RelationshipNotFoundError, - ResourceNotFoundError, ToManyExpectedError, MissingContentTypeError, - ValidationError) - -# TODO: Sorting -# TODO: Pagination -# TODO: Ember-style filtering -# TODO: Simple filtering -# TODO: Complex filtering -# TODO: Bad query param - - -def test_200_on_deletion_from_to_many(comment, client): - payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} - response = client.delete( - '/api/blog-posts/{}/relationships/comments/'.format(comment.post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(200) - for item in response.json_data['data']: - assert {'id', 'type'} == set(item.keys()) - assert payload['data'][0]['id'] not in [str(x['id']) - for x in response.json_data['data'] - ] - - -def test_404_on_resource_not_found(client): - client.delete( - '/api/blog-posts/{}/relationships/comments/'.format(uuid4()), - data='{}', - content_type='application/vnd.api+json').validate( - 404, ResourceNotFoundError) - - -def test_404_on_relationship_not_found(post, client): - client.delete( - '/api/blog-posts/{}/relationships/comment/'.format(post.id), - data='{}', - content_type='application/vnd.api+json').validate( - 404, RelationshipNotFoundError) - - -def test_403_on_permission_denied(user, client): - client.delete( - '/api/users/{}/relationships/logs/'.format(user.id), - data='{"data": []}', - content_type='application/vnd.api+json').validate( - 403, PermissionDeniedError) - - -def test_409_on_to_one_provided(post, client): - client.delete( - '/api/blog-posts/{}/relationships/author/'.format(post.id), - data='{"data": {}}', - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_409_missing_content_type_header(post, client): - client.delete( - '/api/blog-posts/{}/relationships/comment/'.format(post.id), - data='{}').validate(409, MissingContentTypeError) -""" diff --git a/tests/test_relationship_get.py b/tests/test_relationship_get.py deleted file mode 100644 index a996726..0000000 --- a/tests/test_relationship_get.py +++ /dev/null @@ -1,39 +0,0 @@ -"""from sqlalchemy_jsonapi.errors import ( - RelationshipNotFoundError, ResourceNotFoundError, PermissionDeniedError) -from uuid import uuid4 - -# TODO: Sorting -# TODO: Pagination -# TODO: Ember-style filtering -# TODO: Simple filtering -# TODO: Complex filtering -# TODO: Bad query param - - -def test_200_on_to_many(post, client): - response = client.get('/api/blog-posts/{}/relationships/comments/'.format( - post.id)).validate(200) - for item in response.json_data['data']: - assert {'id', 'type'} == set(item.keys()) - - -def test_200_on_to_one(post, client): - response = client.get('/api/blog-posts/{}/relationships/author/'.format( - post.id)).validate(200) - assert response.json_data['data']['type'] == 'users' - - -def test_404_on_resource_not_found(client): - client.get('/api/blog-posts/{}/relationships/comments/'.format(uuid4( - ))).validate(404, ResourceNotFoundError) - - -def test_404_on_relationship_not_found(post, client): - client.get('/api/blog-posts/{}/relationships/comment/'.format( - post.id)).validate(404, RelationshipNotFoundError) - - -def test_403_on_permission_denied(unpublished_post, client): - client.get('/api/blog-posts/{}/relationships/comment/'.format( - unpublished_post.id)).validate(403, PermissionDeniedError) -""" diff --git a/tests/test_relationship_patch.py b/tests/test_relationship_patch.py deleted file mode 100644 index 07e98ef..0000000 --- a/tests/test_relationship_patch.py +++ /dev/null @@ -1,126 +0,0 @@ -"""import json -from uuid import uuid4 - -from sqlalchemy_jsonapi.errors import (PermissionDeniedError, - RelationshipNotFoundError, - ResourceNotFoundError, ValidationError) - -# TODO: Sorting -# TODO: Pagination -# TODO: Ember-style filtering -# TODO: Simple filtering -# TODO: Complex filtering -# TODO: Bad query param - - -def test_200_on_to_one_set_to_resource(post, user, client): - payload = {'data': {'type': 'users', 'id': str(user.id)}} - response = client.patch( - '/api/blog-posts/{}/relationships/author/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(200) - assert response.json_data['data']['id'] == str(user.id) - - -def test_200_on_to_one_set_to_null(post, client): - payload = {'data': None} - response = client.patch( - '/api/blog-posts/{}/relationships/author/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(200) - assert response.json_data['data'] == None - - -def test_200_on_to_many_set_to_resources(post, comment, client): - payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} - response = client.patch( - '/api/blog-posts/{}/relationships/comments/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(200) - assert response.json_data['data'][0]['id'] == str(comment.id) - assert len(response.json_data['data']) == 1 - - -def test_200_on_to_many_set_to_empty(post, client): - payload = {'data': []} - response = client.patch( - '/api/blog-posts/{}/relationships/comments/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(200) - assert len(response.json_data['data']) == 0 - - -def test_409_on_to_one_set_to_empty_list(post, client): - payload = {'data': []} - client.patch( - '/api/blog-posts/{}/relationships/author/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_409_on_to_many_set_to_null(post, client): - payload = {'data': None} - client.patch( - '/api/blog-posts/{}/relationships/comments/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_404_on_resource_not_found(client): - client.patch( - '/api/blog-posts/{}/relationships/comments/'.format(uuid4()), - data='{}', - content_type='application/vnd.api+json').validate( - 404, ResourceNotFoundError) - - -def test_404_on_relationship_not_found(client, post): - client.patch( - '/api/blog-posts/{}/relationships/comment/'.format(post.id), - data='{}', - content_type='application/vnd.api+json').validate( - 404, RelationshipNotFoundError) - - -def test_404_on_related_item_not_found(post, client): - payload = {'data': [{'type': 'blog-comments', 'id': str(uuid4())}]} - client.patch( - '/api/blog-posts/{}/relationships/comments/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate( - 404, ResourceNotFoundError) - - -def test_403_on_permission_denied(user, log, client): - payload = {'data': {'type': 'users', 'id': str(user.id)}} - client.patch( - '/api/logs/{}/relationships/user/'.format(log.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate( - 403, PermissionDeniedError) - - -def test_403_on_permission_denied_on_related(log, user, client): - payload = {'data': {'type': 'logs', 'id': str(log.id)}} - client.patch( - '/api/users/{}/relationships/logs/'.format(user.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate( - 403, PermissionDeniedError) - - -def test_409_on_to_one_with_incompatible_model(post, comment, client): - payload = {'data': {'type': 'blog-comments', 'id': str(comment.id)}} - client.patch( - '/api/blog-posts/{}/relationships/author/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_409_on_to_many_with_incompatible_model(post, client): - payload = {'data': [{'type': 'blog-posts', 'id': str(post.id)}]} - client.patch( - '/api/blog-posts/{}/relationships/author/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) -""" diff --git a/tests/test_relationship_post.py b/tests/test_relationship_post.py deleted file mode 100644 index 2c559fc..0000000 --- a/tests/test_relationship_post.py +++ /dev/null @@ -1,61 +0,0 @@ -"""import json -from uuid import uuid4 - -from sqlalchemy_jsonapi.errors import ValidationError, ResourceNotFoundError, RelationshipNotFoundError - -# TODO: Sorting -# TODO: Pagination -# TODO: Ember-style filtering -# TODO: Simple filtering -# TODO: Complex filtering -# TODO: Bad query param - - -def test_200_on_to_many(comment, post, client): - payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} - response = client.post( - '/api/blog-posts/{}/relationships/comments/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(200) - assert str(comment.id) in [str(x['id']) - for x in response.json_data['data']] - - -def test_409_on_hash_instead_of_array_provided(comment, post, client): - payload = {'data': {'type': 'blog-comments', 'id': str(comment.id)}} - client.post( - '/api/blog-posts/{}/relationships/comments/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_409_on_incompatible_model(user, post, client): - payload = {'data': [{'type': 'users', 'id': str(user.id)}]} - client.post( - '/api/blog-posts/{}/relationships/comments/'.format(post.id), - data=json.dumps(payload), - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_409_on_to_one_relationship(post, client): - client.post( - '/api/blog-posts/{}/relationships/author/'.format(post.id), - data='{}', - content_type='application/vnd.api+json').validate(409, ValidationError) - - -def test_404_on_resource_not_found(client): - client.post( - '/api/blog-posts/{}/relationships/comments/'.format(uuid4()), - data='{}', - content_type='application/vnd.api+json').validate( - 404, ResourceNotFoundError) - - -def test_404_on_relationship_not_found(post, client): - client.post( - '/api/blog-posts/{}/relationships/comment/'.format(post.id), - data='{}', - content_type='application/vnd.api+json').validate( - 404, RelationshipNotFoundError) -"""