diff --git a/README.md b/README.md index 476c826..f74e78d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![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/requirements.txt b/requirements.txt index 9b44940..002def7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ # # pip-compile --output-file requirements.txt requirements.in # - bcrypt==2.0.0 blinker==1.4 cffi==1.7.0 # via bcrypt diff --git a/sqlalchemy_jsonapi/serializer.py b/sqlalchemy_jsonapi/serializer.py index 1185c87..74c101e 100644 --- a/sqlalchemy_jsonapi/serializer.py +++ b/sqlalchemy_jsonapi/serializer.py @@ -1,6 +1,5 @@ -""" -SQLAlchemy-JSONAPI -Serializer +"""SQLAlchemy-JSONAPI Serializer. + Colton J. Provias MIT License """ @@ -19,29 +18,22 @@ from ._version import __version__ -class AttributeActions(Enum): - """ The actions that can be done to an attribute. """ - - GET = 0 - SET = 1 +class Actions(Enum): + """ The actions that can be performed on an attribute or relationship. """ - -class RelationshipActions(Enum): - """ The actions that can be performed on a relationship. """ - - GET = 10 - APPEND = 11 - SET = 12 - DELETE = 13 + GET = 1 + APPEND = 2 + SET = 3 + REMOVE = 4 class Permissions(Enum): """ The permissions that can be set. """ - VIEW = 100 - CREATE = 101 - EDIT = 102 - DELETE = 103 + VIEW = 1 + CREATE = 2 + EDIT = 3 + DELETE = 4 ALL_PERMISSIONS = { @@ -52,45 +44,15 @@ class Permissions(Enum): } -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): +def jsonapi_action(action, *names): + if isinstance(action, Actions): action = [action] def wrapped(fn): if not hasattr(fn, '__jsonapi_action__'): fn.__jsonapi_action__ = set() - 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_desc__ = set() + fn.__jsonapi_desc__ |= set(names) fn.__jsonapi_action__ |= set(action) return fn @@ -98,12 +60,10 @@ 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): - """ - Decorates a function that returns a boolean representing if access is - allowed. + """Decorate a function that returns a boolean representing access. :param permission: The permission to check for :param names: The names to test for. None represents the model. @@ -128,14 +88,14 @@ def __call__(self, fn): return fn #: More consistent name for the decorators -permission_test = PermissionTest +jsonapi_access = 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'}, @@ -158,8 +118,7 @@ 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 an error if - denied. + Check a permission for a given instance or field. Raises error if denied. :param instance: The instance to check :param field: The field name to check or None for instance @@ -175,10 +134,10 @@ def get_attr_desc(instance, attribute, action): :param instance: Model instance :param attribute: Name of the attribute - :param action: AttributeAction + :param action: Action """ descs = instance.__jsonapi_attribute_descriptors__.get(attribute, {}) - if action == AttributeActions.GET: + if action == Actions.GET: check_permission(instance, attribute, Permissions.VIEW) return descs.get(action, lambda x: getattr(x, attribute)) check_permission(instance, attribute, Permissions.EDIT) @@ -194,13 +153,13 @@ def get_rel_desc(instance, key, action): :param action: RelationshipAction """ descs = instance.__jsonapi_rel_desc__.get(key, {}) - if action == RelationshipActions.GET: + if action == Actions.GET: check_permission(instance, key, Permissions.VIEW) return descs.get(action, lambda x: getattr(x, key)) - elif action == RelationshipActions.APPEND: + elif action == Actions.APPEND: check_permission(instance, key, Permissions.CREATE) return descs.get(action, lambda x, v: getattr(x, key).append(v)) - elif action == RelationshipActions.SET: + elif action == Actions.SET: check_permission(instance, key, Permissions.EDIT) return descs.get(action, lambda x, v: setattr(x, key, v)) else: @@ -300,7 +259,8 @@ 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) + 'related': '{}/{}/{}/{}'.format(self.prefix, api_type, obj_id, + rel_key) } def _get_relationship(self, resource, rel_key, permission): @@ -367,8 +327,8 @@ 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__[x]), fields[ - api_type])) + lambda x: instance.__jsonapi_map_to_py__.get(x)), fields.get( + api_type))) else: local_fields = orm_desc_keys @@ -379,7 +339,7 @@ def _render_full_resource(self, instance, include, fields): api_key = instance.__jsonapi_map_to_api__[key] try: - desc = get_rel_desc(instance, key, RelationshipActions.GET) + desc = get_rel_desc(instance, key, Actions.GET) except PermissionDeniedError: continue @@ -446,7 +406,7 @@ def _render_full_resource(self, instance, include, fields): for key in set(orm_desc_keys) - attrs_to_ignore: try: - desc = get_attr_desc(instance, key, AttributeActions.GET) + desc = get_attr_desc(instance, key, Actions.GET) if key in local_fields: to_ret['attributes'][instance.__jsonapi_map_to_api__[ key]] = desc(instance) diff --git a/test.png b/test.png new file mode 100644 index 0000000..ba872e2 Binary files /dev/null and b/test.png differ diff --git a/sqlalchemy_jsonapi/tests/app.py b/tests/app.py similarity index 94% rename from sqlalchemy_jsonapi/tests/app.py rename to tests/app.py index ab262ce..74e0f1a 100644 --- a/sqlalchemy_jsonapi/tests/app.py +++ b/tests/app.py @@ -74,19 +74,19 @@ def validate_password(self, key, password): assert len(password) >= 5, 'Password must be 5 characters or longer.' return password - @permission_test(Permissions.VIEW, 'password') + @jsonapi_access(Permissions.VIEW, 'password') def view_password(self): """ Never let the password be seen. """ return False - @permission_test(Permissions.EDIT) + @jsonapi_access(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) + @jsonapi_access(Permissions.DELETE) def allow_delete(self): """ Just like a popular social media site, we won't delete users. """ return False @@ -115,12 +115,12 @@ def validate_title(self, key, title): title) <= 100, 'Must be 5 to 100 characters long.' return title - @permission_test(Permissions.VIEW) + @jsonapi_access(Permissions.VIEW) def allow_view(self): """ Hide unpublished. """ return self.is_published - @permission_test(INTERACTIVE_PERMISSIONS, 'logs') + @jsonapi_access(INTERACTIVE_PERMISSIONS, 'logs') def prevent_altering_of_logs(self): return False @@ -157,7 +157,7 @@ class Log(Timestamp, db.Model): lazy='joined', backref=backref('logs', lazy='dynamic')) - @permission_test(INTERACTIVE_PERMISSIONS) + @jsonapi_access(INTERACTIVE_PERMISSIONS) def block_interactive(cls): return False diff --git a/sqlalchemy_jsonapi/tests/conftest.py b/tests/conftest.py similarity index 100% rename from sqlalchemy_jsonapi/tests/conftest.py rename to tests/conftest.py diff --git a/sqlalchemy_jsonapi/tests/test_collection_get.py b/tests/test_collection_get.py similarity index 95% rename from sqlalchemy_jsonapi/tests/test_collection_get.py rename to tests/test_collection_get.py index a66a7e6..9cc3903 100644 --- a/sqlalchemy_jsonapi/tests/test_collection_get.py +++ b/tests/test_collection_get.py @@ -1,11 +1,7 @@ from sqlalchemy_jsonapi.errors import BadRequestError, NotSortableError -# TODO: Ember-style filtering -# TODO: Simple filtering -# TODO: Complex filtering -# TODO: Bad query param - +# TODO: Vanilla def test_200_with_no_querystring(bunch_of_posts, client): response = client.get('/api/blog-posts').validate(200) @@ -13,6 +9,12 @@ def test_200_with_no_querystring(bunch_of_posts, client): 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) assert response.json_data['data'][0]['type'] == 'blog-posts' @@ -27,7 +29,6 @@ def test_200_with_including_model_and_including_inbetween(bunch_of_posts, 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) @@ -36,6 +37,9 @@ def test_200_with_multiple_includes(bunch_of_posts, client): 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) @@ -47,7 +51,7 @@ 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(item['attributes'].keys()) + assert set() == set(item['attributes'].keys()) assert len(item['relationships']) == 0 @@ -74,6 +78,9 @@ def test_200_with_single_field_across_a_relationship(bunch_of_posts, client): 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) title_list = [x['attributes']['title'] for x in response.json_data['data']] @@ -101,6 +108,9 @@ def test_409_when_given_a_missing_field_for_sorting(bunch_of_posts, client): 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) @@ -121,3 +131,6 @@ def test_200_when_pagination_is_out_of_range(bunch_of_posts, client): 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/tests/test_collection_post.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_collection_post.py rename to tests/test_collection_post.py diff --git a/sqlalchemy_jsonapi/tests/test_related_get.py b/tests/test_related_get.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_related_get.py rename to tests/test_related_get.py diff --git a/sqlalchemy_jsonapi/tests/test_relationship_delete.py b/tests/test_relationship_delete.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_relationship_delete.py rename to tests/test_relationship_delete.py diff --git a/sqlalchemy_jsonapi/tests/test_relationship_get.py b/tests/test_relationship_get.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_relationship_get.py rename to tests/test_relationship_get.py diff --git a/sqlalchemy_jsonapi/tests/test_relationship_patch.py b/tests/test_relationship_patch.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_relationship_patch.py rename to tests/test_relationship_patch.py diff --git a/sqlalchemy_jsonapi/tests/test_relationship_post.py b/tests/test_relationship_post.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_relationship_post.py rename to tests/test_relationship_post.py diff --git a/sqlalchemy_jsonapi/tests/test_resource_delete.py b/tests/test_resource_delete.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_resource_delete.py rename to tests/test_resource_delete.py diff --git a/sqlalchemy_jsonapi/tests/test_resource_get.py b/tests/test_resource_get.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_resource_get.py rename to tests/test_resource_get.py diff --git a/sqlalchemy_jsonapi/tests/test_resource_patch.py b/tests/test_resource_patch.py similarity index 96% rename from sqlalchemy_jsonapi/tests/test_resource_patch.py rename to tests/test_resource_patch.py index 5ee3b40..d15a73a 100644 --- a/sqlalchemy_jsonapi/tests/test_resource_patch.py +++ b/tests/test_resource_patch.py @@ -1,10 +1,8 @@ import json from uuid import uuid4 -from sqlalchemy_jsonapi.errors import ( - BadRequestError, PermissionDeniedError, ResourceNotFoundError, - RelatedResourceNotFoundError, RelationshipNotFoundError, ValidationError, - MissingTypeError) +from sqlalchemy_jsonapi.errors import (BadRequestError, PermissionDeniedError, + ResourceNotFoundError, ValidationError) # TODO: Sparse Fieldsets diff --git a/sqlalchemy_jsonapi/tests/test_serializer.py b/tests/test_serializer.py similarity index 100% rename from sqlalchemy_jsonapi/tests/test_serializer.py rename to tests/test_serializer.py