Skip to content

Commit

Permalink
Merge pull request #23 from jmcarp/hyperlink-related
Browse files Browse the repository at this point in the history
Add `HyperlinkRelated` field for SQLAlchemy integration.
  • Loading branch information
sloria committed Jul 4, 2015
2 parents 848bab7 + 7be896b commit 7f10dbe
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 48 deletions.
8 changes: 5 additions & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ env:
- MARSHMALLOW_VERSION=""

install:
- pip install -U -r dev-requirements.txt
- pip install -U .
- pip install -U marshmallow"$MARSHMALLOW_VERSION" --pre
- travis_retry pip install -U -r dev-requirements.txt
- travis_retry pip install -U .
- travis_retry pip install -U marshmallow"$MARSHMALLOW_VERSION" --pre
- travis_retry pip install -U git+https://github.com/marshmallow-code/marshmallow@dev
- travis_retry pip install -U git+https://github.com/marshmallow-code/marshmallow-sqlalchemy@dev
before_script:
- flake8 .
script: py.test
3 changes: 0 additions & 3 deletions flask_marshmallow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ def __init__(self, app=None):
self.Schema = Schema
if has_sqla:
self.ModelSchema = sqla.ModelSchema
self.HyperlinkModelSchema = sqla.HyperlinkModelSchema
_attach_fields(self)
if app is not None:
self.init_app(app)
Expand All @@ -119,12 +118,10 @@ def init_app(self, app):
:param Flask app: The Flask application object.
"""
app.config.setdefault('MARSHMALLOW_LINK_ATTRIBUTE', 'url')
app.extensions = getattr(app, 'extensions', {})

# If using Flask-SQLAlchemy, attach db.session to ModelSchema
if has_sqla and 'sqlalchemy' in app.extensions:
db = app.extensions['sqlalchemy'].db
self.ModelSchema.OPTIONS_CLASS.session = db.session
self.HyperlinkModelSchema.OPTIONS_CLASS.session = db.session
app.extensions[EXTENSION_NAME] = self
58 changes: 33 additions & 25 deletions flask_marshmallow/sqla.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
`ModelSchema <marshmallow_sqlalchemy.ModelSchema>` classes that use the scoped session
from Flask-SQLALchemy.
"""
from flask import current_app
from flask import url_for, current_app
from six.moves.urllib import parse

import marshmallow_sqlalchemy as msqla
from marshmallow.exceptions import ValidationError
from .schema import Schema

class DummySession(object):
Expand Down Expand Up @@ -41,31 +43,37 @@ class ModelSchema(msqla.ModelSchema, Schema):
"""
OPTIONS_CLASS = SchemaOpts

def hyperlink_keygetter(obj):
link_attribute = current_app.config['MARSHMALLOW_LINK_ATTRIBUTE']
try:
return getattr(obj, link_attribute)
except AttributeError:
# Reraise with better error message
raise AttributeError(
'Objects that get serialized by HyperlinkModelSchema must '
'have a "{0}" attribute.'.format(link_attribute)
)

class HyperlinkSchemaOpts(SchemaOpts):
def __init__(self, meta):
if not hasattr(meta, 'keygetter'):
meta.keygetter = hyperlink_keygetter
super(HyperlinkSchemaOpts, self).__init__(meta)
class HyperlinkRelated(msqla.fields.Related):

def __init__(self, endpoint, url_key='id', external=False, **kwargs):
super(HyperlinkRelated, self).__init__(**kwargs)
self.endpoint = endpoint
self.url_key = url_key
self.external = external

class HyperlinkModelSchema(msqla.ModelSchema):
"""A `ModelSchema <marshmallow_sqlalchemy.ModelSchema>` that serializes relationships
to hyperlinks. Related models MUST have a ``url`` attribute or property (unless the
``MARSHMALLOW_LINK_ATTRIBUTE`` app config option is set).
def _serialize(self, value, attr, obj):
key = super(HyperlinkRelated, self)._serialize(value, attr, obj)
kwargs = {self.url_key: key}
return url_for(self.endpoint, _external=self.external, **kwargs)

See `marshmallow_sqlalchemy.ModelSchema` for more details
on the `ModelSchema` API.
"""
def _deserialize(self, value):
if self.external:
parsed = parse.urlparse(value)
value = parsed.path
endpoint, kwargs = self.adapter.match(value)
if endpoint != self.endpoint:
raise ValidationError(
(
'Parsed endpoint "{endpoint}" from URL "{value}"; expected '
'"{self.endpoint}"'
).format(**locals())
)
if self.url_key not in kwargs:
raise ValidationError(
'URL pattern "{self.url_key}" not found in {kwargs!r}'.format(**locals())
)
return super(HyperlinkRelated, self)._deserialize(kwargs[self.url_key])

OPTIONS_CLASS = HyperlinkSchemaOpts
@property
def adapter(self):
return current_app.url_map.bind('')
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
REQUIRES = [
'Flask',
'marshmallow>=1.2.0',
'six>=1.9.0',
]

class PyTest(TestCommand):
Expand Down
83 changes: 67 additions & 16 deletions test_flask_marshmallow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from flask_marshmallow.fields import _tpl

from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow.sqla import HyperlinkRelated

import marshmallow

Expand Down Expand Up @@ -341,40 +342,90 @@ class Meta:
resp = author_schema.jsonify(author)
assert isinstance(resp, BaseResponse)

def test_can_declare_hyperlinked_model_schemas(self, extma, models, db, extapp):
class AuthorSchema(extma.HyperlinkModelSchema):
def test_hyperlink_related_field(self, extma, models, db, extapp):
class BookSchema(extma.ModelSchema):
class Meta:
model = models.Author
model = models.Book
author = HyperlinkRelated('author')

book_schema = BookSchema()

author = models.Author(name='Chuck Paluhniuk')
book = models.Book(title='Fight Club', author=author)
db.session.add(author)
db.session.add(book)
db.session.flush()

book_result = book_schema.dump(book)
assert book_result.data['author'] == author.url

deserialized = book_schema.load(book_result.data)
assert deserialized.data.author == author

class BookSchema(extma.HyperlinkModelSchema):
def test_hyperlink_related_field_errors(self, extma, models, db, extapp):
class BookSchema(extma.ModelSchema):
class Meta:
model = models.Book
author = HyperlinkRelated('author')

author_schema = AuthorSchema()
book_schema = BookSchema()

author = models.Author(name='Chuck Paluhniuk')
book = models.Book(title='Fight Club', author=author)
db.session.add(author)
db.session.add(book)
db.session.commit()
db.session.flush()

# Deserialization fails on bad endpoint
book_result = book_schema.dump(book)
assert book_result.data['author'] == author.url
book_result.data['author'] = book.url
deserialized = book_schema.load(book_result.data)
assert deserialized.data.author is None
assert 'expected "author"' in deserialized.errors['author'][0]

author_result = author_schema.dump(author)
assert author_result.data['books'][0] == book.url
# Deserialization fails on bad URL key
book_result = book_schema.dump(book)
book_schema.fields['author'].url_key = 'pk'
deserialized = book_schema.load(book_result.data)
assert deserialized.data.author is None
assert 'URL pattern "pk" not found' in deserialized.errors['author'][0]

def test_hyperlink_related_field_external(self, extma, models, db, extapp):
class BookSchema(extma.ModelSchema):
class Meta:
model = models.Book
author = HyperlinkRelated('author', external=True)

extapp.config['MARSHMALLOW_LINK_ATTRIBUTE'] = 'absolute_url'
book_schema = BookSchema()

author = models.Author(name='Chuck Paluhniuk')
book = models.Book(title='Fight Club', author=author)
db.session.add(author)
db.session.add(book)
db.session.flush()

book_result = book_schema.dump(book)
assert book_result.data['author'] == author.absolute_url

deserialized = book_schema.load(book_result.data)
assert deserialized.data.author == author

def test_hyperlink_related_field_list(self, extma, models, db, extapp):
class AuthorSchema(extma.ModelSchema):
class Meta:
model = models.Author
books = extma.List(HyperlinkRelated('book'))

author_schema = AuthorSchema()

author = models.Author(name='Chuck Paluhniuk')
book = models.Book(title='Fight Club', author=author)
db.session.add(author)
db.session.add(book)
db.session.flush()

author_result = author_schema.dump(author)
assert author_result.data['books'][0] == book.absolute_url
assert author_result.data['books'][0] == book.url

author = author_schema.load(author_result.data).data
assert type(author) == models.Author
assert type(author.books[0]) == models.Book
assert author.books[0].title == book.title
assert author.books[0].id == book.id
deserialized = author_schema.load(author_result.data)
assert deserialized.data.books[0] == book
4 changes: 3 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ deps=
pytest
Flask
mock
marshmallow
Flask-SQLAlchemy
git+https://github.com/marshmallow-code/marshmallow@dev
git+https://github.com/marshmallow-code/marshmallow-sqlalchemy@dev
commands=
py.test test_flask_marshmallow.py

0 comments on commit 7f10dbe

Please sign in to comment.