From 8b9aebd6e05a0bb42d053962b63a9b56a55b2b51 Mon Sep 17 00:00:00 2001 From: Jon Banafato Date: Mon, 4 Jul 2016 11:06:40 -0400 Subject: [PATCH] Use Marshmallow for serialization Our use of Flask-Restful's marshalling functionality is hackish. There exists significant support for moving Flask-Restful's internal marshalling to Marshmallow [1], so we should switch to that. Flask-Marshmallow is used for convenience functions (e.g. `jsonify`). This changeset is strictly a port from Flask-Restful's builtin marshalling mechanism to Marshmallow. TODOs and FIXMEs still exist and should be addressed in a separate change. Flask-Marshmallow relies on changes to `flask.jsonify` made in Flask 0.11, so our version must be upgraded in order to use it. 0.11.1 is the latest stable release of Flask. [1] https://github.com/flask-restful/flask-restful/issues/335 --- pygotham/api/__init__.py | 3 ++ pygotham/api/core.py | 5 ++ pygotham/api/events.py | 13 ++--- pygotham/api/fields.py | 100 --------------------------------------- pygotham/api/schedule.py | 8 ++-- pygotham/api/schema.py | 87 ++++++++++++++++++++++++++++++++++ requirements.in | 1 + requirements.txt | 6 ++- 8 files changed, 111 insertions(+), 112 deletions(-) create mode 100644 pygotham/api/core.py delete mode 100644 pygotham/api/fields.py create mode 100644 pygotham/api/schema.py diff --git a/pygotham/api/__init__.py b/pygotham/api/__init__.py index 40b1191..d5eac94 100644 --- a/pygotham/api/__init__.py +++ b/pygotham/api/__init__.py @@ -2,6 +2,8 @@ from pygotham import factory +from .core import marshmallow + __all__ = ('create_app',) @@ -11,4 +13,5 @@ def create_app(settings_override=None): :param settings_override: a ``dict`` of settings to override. """ app = factory.create_app(__name__, __path__, settings_override) + marshmallow.init_app(app) return app diff --git a/pygotham/api/core.py b/pygotham/api/core.py new file mode 100644 index 0000000..41dad7e --- /dev/null +++ b/pygotham/api/core.py @@ -0,0 +1,5 @@ +"""Core API application components.""" + +from flask_marshmallow import Marshmallow + +marshmallow = Marshmallow() diff --git a/pygotham/api/events.py b/pygotham/api/events.py index c1d62d7..86f01f5 100644 --- a/pygotham/api/events.py +++ b/pygotham/api/events.py @@ -1,11 +1,11 @@ """Event-related API endpoints.""" from flask import Blueprint -from flask_restful import Api, Resource, marshal_with +from flask_restful import Api, Resource from pygotham.events.models import Event -from .fields import event_fields +from .schema import EventSchema blueprint = Blueprint( @@ -17,17 +17,18 @@ class EventListResource(Resource): """Return all available event data.""" - @marshal_with(event_fields) def get(self): """Event list.""" - return Event.query.filter_by(active=True).all() + schema = EventSchema(many=True) + return schema.jsonify(Event.query.filter_by(active=True).all()) @api.resource('//') class EventResource(Resource): """Return event core data.""" - @marshal_with(event_fields) def get(self, event_id): """Event details.""" - return Event.query.filter_by(active=True, id=event_id).first_or_404() + schema = EventSchema() + return schema.jsonify( + Event.query.filter_by(active=True, id=event_id).first_or_404()) diff --git a/pygotham/api/fields.py b/pygotham/api/fields.py deleted file mode 100644 index 352c95f..0000000 --- a/pygotham/api/fields.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Fieldsets rendered via Flask-Restful.""" - -from flask_restful import fields - -__all__ = ('event_fields', 'user_fields', 'talk_fields') - - -class MockField(fields.Raw): - """Return whatever is passed into the initializer.""" - - def __init__(self, mock_value, **kwargs): - """Store the mock value provided and pass the rest to super().""" - self.mock_value = mock_value - super().__init__(**kwargs) - - def output(self, key, obj): - """Return the mock value provided.""" - return self.mock_value - - -class AttrField(fields.Raw): - """Return the value of an attribute of obj.""" - - def __init__(self, attr, post_processor=None, **kwargs): - """Store the attr name provided and pass the rest to super().""" - self.attr = attr - self.post_processor = post_processor - super().__init__(**kwargs) - - def output(self, key, obj): - """Return the value of attr on obj.""" - parts = self.attr.split('.') - attr = parts.pop(0) - value = getattr(obj, attr) - for part in parts: - if value is None: - return None - value = getattr(value, part) - # HACK: post_processor shouldn't be needed. We should use an - # approach similar to fields.Nested here. - if self.post_processor is not None: - return self.post_processor(value) - return value - -event_fields = { - 'id': fields.Integer, - 'begins': fields.DateTime('iso8601'), - 'ends': fields.DateTime('iso8601'), - 'name': fields.String, - 'registration_url': fields.String, - 'slug': fields.String, -} - -user_fields = { - 'id': fields.Integer, - 'bio': fields.String, - 'email': MockField(''), - 'name': fields.String, - 'picture_url': MockField(None), - 'twitter_id': AttrField('twitter_handle'), -} - -talk_fields = { - 'id': fields.Integer, - # Here, conf_key simply refers to the id. this should be removed in - # the future in favor of simply using the `id` field above - 'conf_key': AttrField('id'), - 'description': fields.String, - 'duration': AttrField('duration.duration'), - 'language': MockField('English'), - # TODO: How should this be generated? - 'summary': AttrField('description'), - 'room': AttrField( - 'presentation.slot.rooms', - lambda rooms: ' & '.join(room.name for room in rooms), - ), - 'room_alias': AttrField( - 'presentation.slot.rooms', - lambda rooms: ' & '.join(room.name for room in rooms), - ), - # NOTE: This should probably be nested instead of inlined this way. - 'start': AttrField( - 'presentation.slot', - lambda s: '{:%Y-%m-%d}T{:%H:%M:%S}'.format(s.day.date, s.start), - ), - # HACK: Generate the recording priority based on recording release - # We probably won't have any 5s, but this is about as correct as the - # mapping can be at the moment. - 'priority': AttrField( - 'recording_release', - lambda released: {True: 9, False: 0, None: 5}[released], - ), - # `released` refers to the talk's video recording release - 'released': AttrField('recording_release'), - # FIXME: What version are the talks to be licensed under? - 'license': MockField('Creative Commons'), - 'tags': MockField([]), - 'title': AttrField('name'), - 'user': fields.Nested(user_fields), -} diff --git a/pygotham/api/schedule.py b/pygotham/api/schedule.py index d75d690..d202275 100644 --- a/pygotham/api/schedule.py +++ b/pygotham/api/schedule.py @@ -1,12 +1,12 @@ """Schedule-related API endpoints.""" from flask import Blueprint -from flask_restful import Api, Resource, marshal_with +from flask_restful import Api, Resource from pygotham.events.models import Event from pygotham.talks.models import Talk -from .fields import talk_fields +from .schema import TalkSchema blueprint = Blueprint( 'schedule', @@ -21,9 +21,9 @@ class TalkResource(Resource): """Represents talks and their place on the schedule.""" - @marshal_with(talk_fields) def get(self, event_id): """Return a list of accepted talks.""" event = Event.query.get_or_404(event_id) talks = Talk.query.filter_by(event=event, status='accepted').all() - return talks + schema = TalkSchema(many=True) + return schema.jsonify(talks) diff --git a/pygotham/api/schema.py b/pygotham/api/schema.py new file mode 100644 index 0000000..72fe10e --- /dev/null +++ b/pygotham/api/schema.py @@ -0,0 +1,87 @@ +"""Fieldsets rendered via Flask-Restful.""" + +from pygotham.models import Event, User, Talk + +from .core import marshmallow + +__all__ = ('EventSchema', 'UserSchema', 'TalkSchema') + + +class EventSchema(marshmallow.Schema): + """Serialization rules for Event objects.""" + + class Meta: + model = Event + fields = ('id', 'begins', 'ends', 'name', 'registration_url', 'slug') + + +class UserSchema(marshmallow.Schema): + """Serialization rules for User objects.""" + + class Meta: + model = User + additional = ('id', 'bio', 'name') + + email = marshmallow.Function(lambda user: '') + picture_url = marshmallow.Function(lambda user: None) + twitter_id = marshmallow.Function(lambda user: user.twitter_handle) + + +class TalkSchema(marshmallow.Schema): + """Serialization rules for Talk objects.""" + + class Meta: + model = Talk + additional = ('id', 'description') + + conf_key = marshmallow.Function(lambda talk: talk.id) + duration = marshmallow.Function(lambda talk: talk.duration.duration) + language = marshmallow.Function(lambda talk: 'English') + # FIXME: What version are the talks to be licensed under? + license = marshmallow.Function(lambda talk: 'Creative Commons') + priority = marshmallow.Method('get_recording_priority') + released = marshmallow.Function(lambda talk: talk.recording_release) + # TODO: Replace this with a nested SlotSchema. + room = marshmallow.Method('get_room') + room_alias = marshmallow.Method('get_room') + # TODO: Replace this with a nested SlotSchema. + start = marshmallow.Method('get_start_time') + summary = marshmallow.Function(lambda talk: talk.description) + tags = marshmallow.Function(lambda talk: []) + title = marshmallow.Function(lambda talk: talk.name) + user = marshmallow.Nested(UserSchema) + + def get_recording_priority(self, talk): + """Get the numerical recording priority for a talk. + + Args: + talk (pygotham.talks.models.Talk): The talk. + """ + # HACK: Generate the recording priority based on recording + # release. We probably won't have any 5s, but this is about as + # correct as the mapping can be at the moment. + priority_mapping = {True: 9, False: 0, None: 5} + return priority_mapping[talk.recording_release] + + def get_room(self, talk): + """Get a one line representation of a talk's scheduled room. + + In most cases, talks are in one room. Occasionally, however, a + talk may span multiple rooms. When that happens, return a + descriptive string combining room names. + + Args: + talk (pygotham.talks.models.Talk): The talk. + """ + return ' & '.join(room.name for room in talk.presentation.slot.rooms) + + def get_start_time(self, talk): + """Return an IOS8601 formatted start time. + + Args: + talk (pygotham.talks.models.Talk): The talk. + """ + return '{:%Y-%m-%d}T{:%H:%M:%S}'.format( + talk.presentation.slot.day.date, + talk.presentation.slot.start, + ) diff --git a/requirements.in b/requirements.in index c6e4890..68f3292 100644 --- a/requirements.in +++ b/requirements.in @@ -8,6 +8,7 @@ Flask-Admin Flask-Copilot Flask-Login==0.2.11 Flask-Mail +Flask-Marshmallow Flask-Migrate Flask-Principal Flask-RESTful diff --git a/requirements.txt b/requirements.txt index c28d175..9cd8d28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ Flask-Admin==1.4.0 flask-copilot==0.2.0 Flask-Login==0.2.11 # via flask-security Flask-Mail==0.9.1 # via flask-security +Flask-Marshmallow==0.7.0 Flask-Migrate==1.7.0 Flask-Principal==0.4.0 # via flask-security Flask-RESTful==0.3.5 @@ -24,7 +25,7 @@ Flask-Script==2.0.5 # via flask-migrate Flask-Security==1.7.4 Flask-SQLAlchemy==2.1 # via flask-migrate Flask-WTF==0.12 # via flask-security -Flask==0.10.1 # via flask-admin, flask-copilot, flask-login, flask-mail, flask-migrate, flask-principal, flask-restful, flask-script, flask-security, flask-sqlalchemy, flask-wtf +Flask==0.11.1 # via flask-admin, flask-copilot, flask-login, flask-mail, flask-marshmallow, flask-migrate, flask-principal, flask-restful, flask-script, flask-security, flask-sqlalchemy, flask-wtf html5lib==0.9999999 # via bleach infinity==1.3 # via intervals intervals==0.6.0 # via wtforms-components @@ -32,6 +33,7 @@ itsdangerous==0.24 # via flask, flask-security Jinja2==2.8 # via flask Mako==1.0.3 # via alembic MarkupSafe==0.23 # via jinja2, mako +marshmallow==2.8.0 # via flask-marshmallow passlib==1.6.5 # via flask-security psycopg2==2.6.1 python-dateutil==2.4.2 # via aniso8601, arrow @@ -39,7 +41,7 @@ python-editor==0.5 # via alembic python-slugify==1.2.0 pytz==2015.7 # via flask-restful raven==5.10.2 -six==1.10.0 # via bleach, flask-restful, html5lib, python-dateutil, sqlalchemy-utils, validators, wtforms-alchemy, wtforms-components +six==1.10.0 # via bleach, flask-marshmallow, flask-restful, html5lib, python-dateutil, sqlalchemy-utils, validators, wtforms-alchemy, wtforms-components sortedcontainers==1.4.4 # via flask-copilot SQLAlchemy-Utils==0.30.0 # via wtforms-alchemy, wtforms-components SQLAlchemy==0.9.10 # via alembic, flask-sqlalchemy, sqlalchemy-utils, wtforms-alchemy, wtforms-components