From 63f9f45f18235fc6d272a138075605297150876f Mon Sep 17 00:00:00 2001 From: Lukas Erlacher Date: Mon, 8 Jan 2018 10:55:58 +1100 Subject: [PATCH 1/6] Fix #66 Fix copy&paste error in relationship descriptor parsing --- sqlalchemy_jsonapi/serializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlalchemy_jsonapi/serializer.py b/sqlalchemy_jsonapi/serializer.py index fd33e64..bcda40e 100644 --- a/sqlalchemy_jsonapi/serializer.py +++ b/sqlalchemy_jsonapi/serializer.py @@ -266,7 +266,7 @@ def __init__(self, base, prefix=''): } rels_desc = model.__jsonapi_rel_desc__ for relationship in prop_value.__jsonapi_desc_for_rels__: - rels_desc.setdefault(attribute, defaults) + rels_desc.setdefault(relationship, defaults) rel_desc = rels_desc[relationship] for action in prop_value.__jsonapi_action__: rel_desc[action] = prop_value From f9328b79c5479d06cbe5c287aada383a8a9eeded Mon Sep 17 00:00:00 2001 From: Lukas Erlacher Date: Mon, 8 Jan 2018 10:58:55 +1100 Subject: [PATCH 2/6] Fix #43 Fixes a bad if condition that doesn't work --- sqlalchemy_jsonapi/serializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlalchemy_jsonapi/serializer.py b/sqlalchemy_jsonapi/serializer.py index bcda40e..95d6b67 100644 --- a/sqlalchemy_jsonapi/serializer.py +++ b/sqlalchemy_jsonapi/serializer.py @@ -1043,7 +1043,7 @@ def post_collection(self, session, data, api_type): raise BadRequestError( '{} must be an array'.format(key)) for item in data_rel: - if not {'type', 'id'} in set(item.keys()): + if 'type' not in item.keys() or 'id' not in item.keys(): raise BadRequestError( '{} must have type and id keys'.format(key)) # pragma: no cover From e920f8d21b5230de10d32edada23102290e1a9b3 Mon Sep 17 00:00:00 2001 From: Lukas Erlacher Date: Mon, 8 Jan 2018 14:10:42 +1100 Subject: [PATCH 3/6] Make relationship_descriptor tested Adds a no-op relationship_descriptor to the test app to make sure it gets test coverage. --- sqlalchemy_jsonapi/tests/app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sqlalchemy_jsonapi/tests/app.py b/sqlalchemy_jsonapi/tests/app.py index 8dd0c17..7624994 100644 --- a/sqlalchemy_jsonapi/tests/app.py +++ b/sqlalchemy_jsonapi/tests/app.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import backref, relationship, validates from sqlalchemy_jsonapi import ( FlaskJSONAPI, Permissions, permission_test, Method, Endpoint, + relationship_descriptor, RelationshipActions, INTERACTIVE_PERMISSIONS) from sqlalchemy_utils import EmailType, PasswordType, Timestamp, UUIDType @@ -140,6 +141,12 @@ class BlogComment(Timestamp, db.Model): lazy='joined', backref=backref('comments', lazy='dynamic')) + + @relationship_descriptor(RelationshipActions.GET, 'post') + def post_get(self): + """No-OP Relationship descriptor to exercise relationship_descriptor""" + return self.post + author = relationship('User', lazy='joined', backref=backref('comments', From ff0f02d65cb9a3e0c4efd2a67c7cd8cf99178630 Mon Sep 17 00:00:00 2001 From: Lukas Erlacher Date: Mon, 8 Jan 2018 14:15:25 +1100 Subject: [PATCH 4/6] Fix relationship_descriptor docs rel_descriptor was renamed to relationship_descriptor --- docs/models.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/models.rst b/docs/models.rst index 8179abf..1ad724f 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -49,25 +49,27 @@ Relationship's come in two flavors: to-one and to-many (or tranditional and LDS-flavored if you prefer those terms). To one descriptors have the actions GET and SET:: - @rel_descriptor(RelationshipActions.GET, 'significant_other') + from sqlalchemy_jsonapi import relationship_descriptor, RelationshipActions + + @relationship_descriptor(RelationshipActions.GET, 'significant_other') def getter(self): # ... - @rel_descriptor(RelationshipActions.SET, 'significant_other') + @relationship_descriptor(RelationshipActions.SET, 'significant_other') def setter(self, value): # ... To-many have GET, APPEND, and DELETE:: - @rel_descriptor(RelationshipActions.GET, 'angry_exes') + @relationship_descriptor(RelationshipActions.GET, 'angry_exes') def getter(self): # ... - @rel_descriptor(RelationshipActions.APPEND, 'angry_exes') + @relationship_descriptor(RelationshipActions.APPEND, 'angry_exes') def appender(self): # ... - @rel_descriptor(RelationshipActions.DELETE, 'angry_exes') + @relationship_descriptor(RelationshipActions.DELETE, 'angry_exes') def remover(self): # ... From fe9d6f29dac9bfcc9e5052ddd575cf85e8cc83b4 Mon Sep 17 00:00:00 2001 From: Lukas Erlacher Date: Mon, 8 Jan 2018 15:56:15 +1100 Subject: [PATCH 5/6] Add test for relationship array post --- sqlalchemy_jsonapi/tests/app.py | 22 ++++++++++- sqlalchemy_jsonapi/tests/conftest.py | 9 ++++- .../tests/test_collection_post.py | 37 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/sqlalchemy_jsonapi/tests/app.py b/sqlalchemy_jsonapi/tests/app.py index 7624994..cbf9663 100644 --- a/sqlalchemy_jsonapi/tests/app.py +++ b/sqlalchemy_jsonapi/tests/app.py @@ -9,7 +9,7 @@ from flask import Flask, request from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import Boolean, Column, ForeignKey, Unicode, UnicodeText +from sqlalchemy import Boolean, Column, ForeignKey, Unicode, UnicodeText, Table from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import backref, relationship, validates from sqlalchemy_jsonapi import ( @@ -92,6 +92,10 @@ def allow_delete(self): """ Just like a popular social media site, we won't delete users. """ return False +PostTags = Table('post_tag', db.Model.metadata, + Column('post_id', UUIDType, ForeignKey('posts.id')), + Column('tag_id', UUIDType, ForeignKey('tags.id')) + ) class BlogPost(Timestamp, db.Model): """Post model, as if this is a blog.""" @@ -110,6 +114,10 @@ class BlogPost(Timestamp, db.Model): backref=backref('posts', lazy='dynamic')) + tags = relationship("BlogTag", + secondary=PostTags, + back_populates="posts") + @validates('title') def validate_title(self, key, title): """Keep titles from getting too long.""" @@ -126,6 +134,18 @@ def allow_view(self): def prevent_altering_of_logs(self): return False +class BlogTag(Timestamp, db.Model): + """Blogs can have tags now""" + + __tablename__ = 'tags' + + id = Column(UUIDType, default=uuid4, primary_key=True) + slug = Column(Unicode(100), unique=True) + description = Column(UnicodeText) + + posts = relationship("BlogPost", + secondary=PostTags, + back_populates="tags") class BlogComment(Timestamp, db.Model): """Comment for each Post.""" diff --git a/sqlalchemy_jsonapi/tests/conftest.py b/sqlalchemy_jsonapi/tests/conftest.py index 7ab0b43..58994b3 100644 --- a/sqlalchemy_jsonapi/tests/conftest.py +++ b/sqlalchemy_jsonapi/tests/conftest.py @@ -12,7 +12,7 @@ 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 app import app, User, BlogPost, BlogComment, BlogTag, Log from faker import Faker Session = sessionmaker() @@ -116,6 +116,13 @@ def bunch_of_posts(user, session): BlogComment(author=user, content=fake.paragraph())) session.commit() +@pytest.fixture +def bunch_of_tags(session): + tags = [BlogTag(slug=fake.word(), description=fake.text()) for x in range(3)] + for tag in tags: + session.add(tag) + session.commit() + return tags @pytest.fixture def comment(user, post, session): diff --git a/sqlalchemy_jsonapi/tests/test_collection_post.py b/sqlalchemy_jsonapi/tests/test_collection_post.py index ebacbcb..e904fc5 100644 --- a/sqlalchemy_jsonapi/tests/test_collection_post.py +++ b/sqlalchemy_jsonapi/tests/test_collection_post.py @@ -57,6 +57,43 @@ def test_200_resource_creation_with_relationships(user, client): 'id' ] == str(user.id) +def test_200_resource_creation_with_relationship_array(user, bunch_of_tags, 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) + } + }, + 'tags': { + 'data': [{ + 'type': 'blog-tags', + 'id': str(tag.id) + } for tag in bunch_of_tags + ] + } + } + } + } + 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'}} From 6defe78137c0ec34c00abd025408c6602768043d Mon Sep 17 00:00:00 2001 From: Lukas Erlacher Date: Mon, 8 Jan 2018 16:22:00 +1100 Subject: [PATCH 6/6] Improve serializer bad relationship error Fixes a possible string formatting error in the error clause --- sqlalchemy_jsonapi/serializer.py | 14 +++++++--- .../tests/test_collection_post.py | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/sqlalchemy_jsonapi/serializer.py b/sqlalchemy_jsonapi/serializer.py index 95d6b67..8379eac 100644 --- a/sqlalchemy_jsonapi/serializer.py +++ b/sqlalchemy_jsonapi/serializer.py @@ -47,6 +47,12 @@ class Permissions(Enum): EDIT = 102 DELETE = 103 +class MissingKey: + def __init__(self, elem): + self.elem = elem + + def __repr__(self): + return '<{} elem={}>'.format(self.__class__.__name__, self.elem) ALL_PERMISSIONS = { Permissions.VIEW, Permissions.CREATE, Permissions.EDIT, Permissions.DELETE @@ -980,15 +986,15 @@ def post_collection(self, session, data, api_type): data['data'].setdefault('attributes', {}) data_keys = set(map(( - lambda x: resource.__jsonapi_map_to_py__.get(x, None)), + lambda x: resource.__jsonapi_map_to_py__.get(x, MissingKey(x))), data['data'].get('relationships', {}).keys())) model_keys = set(resource.__mapper__.relationships.keys()) if not data_keys <= model_keys: + data_keys = set([key.elem if isinstance(key, MissingKey) else key for key in data_keys]) # pragma: no cover raise BadRequestError( - '{} not relationships for {}'.format( - ', '.join(list(data_keys - - model_keys)), model.__jsonapi_type__)) + '{} not relationships for {}'.format( + ', '.join([repr(key) for key in list(data_keys - model_keys)]), model.__jsonapi_type__)) attrs_to_ignore = {'__mapper__', 'id'} diff --git a/sqlalchemy_jsonapi/tests/test_collection_post.py b/sqlalchemy_jsonapi/tests/test_collection_post.py index e904fc5..bd76607 100644 --- a/sqlalchemy_jsonapi/tests/test_collection_post.py +++ b/sqlalchemy_jsonapi/tests/test_collection_post.py @@ -2,7 +2,7 @@ from sqlalchemy_jsonapi.errors import ( InvalidTypeForEndpointError, MissingTypeError, PermissionDeniedError, - ValidationError, MissingContentTypeError) + ValidationError, MissingContentTypeError, BadRequestError) from faker import Faker fake = Faker() @@ -183,3 +183,28 @@ def test_409_for_wrong_field_name(client): '/api/users/', data=json.dumps(payload), content_type='application/vnd.api+json').validate( 409, ValidationError) + + +def test_400_for_unknown_relationship_type(user, client): + payload = { + 'data': { + 'type': 'blog-posts', + 'attributes': { + 'title': 'Some title', + 'content': 'Hello, World!', + 'is-published': True + }, + 'relationships': { + 'bogon': { + 'data': { + 'type': 'users', + 'id': str(user.id) + } + } + } + } + } + client.post( + '/api/blog-posts/', data=json.dumps(payload), + content_type='application/vnd.api+json').validate( + 400, BadRequestError)