diff --git a/adaptable/__init__.py b/adaptable/__init__.py new file mode 100644 index 0000000..ec46a6d --- /dev/null +++ b/adaptable/__init__.py @@ -0,0 +1,180 @@ +"""adaptable. + +A serialization middleware. +""" +from abc import abstractmethod + + +class _AdapterRegistry(type): + """Adapter regsitry. + + Attributes: + adapters (dict): Name, object pairs. + """ + + adapters = {} + + def __new__(cls, name, bases, attrs): + new_cls = type.__new__(cls, name, bases, attrs) + cls.adapters[name] = new_cls + return new_cls + + +class Adapter(metaclass=_AdapterRegistry): + """Serialization abstraction layer. + + "deserialize" accepts an unvalidated, raw dictionary and returns a + validated dictionary. It will likely implement your "view" + attribute's deserialization functionality. This method can be as + simple as calling your view or can require complex field marshaling + and schema generation. The method should accept any number of + keyword arguments that are necessary to complete the operation. + + "serialize" accepts a model instance and returns serialized output. + Complex marshaling and schema generation can be applied here. This + method should only ever accept one required "model" argument. + + "fetch_all" should return a list of model instances that were + retrieved by a "select" query operation. + + "fetch_one" should return a model instance that was retrieved by a + "select" query operation. + + "make_query" should accept any number of keyword arguments and + apply those keyword arguments as query filters. The returned object + should be a callable capable of communicating with a database and + returning the desired output. + + Attributes: + model (object): Model object type. + view (object): Schema object type. + """ + + model = None + view = None + + @abstractmethod + def deserialize(self, form: dict) -> dict: + """Return a deserialized dictionary. + + Keyword arguments: + form (dict): Dictionary. + """ + return + + def deserialize_all(self, forms: list) -> list: + """Return a set of deserialized dictionaries. + + Keyword arguments: + forms (list): List of dictionaries. + """ + return [self.deserialize(form) for form in forms] + + @abstractmethod + def serialize(self, model) -> dict: + """Return a serialized model object. + + Keyword arguments: + model: Model class instance. + """ + return + + def serialize_all(self, models: list) -> list: + """Return a set of serialized models. + + Keyword arguments: + models (list): List of model class instances. + """ + return [self.serialize(model) for model in models] + + def fetch(self, id, **kwargs): + """Return a model instance. + + A query is constructed from the "make_query" method prior to + fetching the model instance. + + Keyword arguments: + id: Unique model identifier. + """ + query = self.make_query(**kwargs) + return self.fetch_one(query, id) + + @abstractmethod + def fetch_compounded(self, id, **kwargs): + """Return a single-object query including joined relationships. + + Keyword arguments: + id: Unique model identifier. + filters (dict): Column, value filter options. + """ + return + + @abstractmethod + def fetch_all(self, query) -> list: + """Return a list of model instances. + + Keyword arguments: + query: ORM query object. + """ + return + + @abstractmethod + def fetch_one(self, query, id): + """Return a model instance or None. + + Keyword arguments: + query: ORM query object. + id: Unique model identifier. + """ + return + + @abstractmethod + def make_query(self): + """Return a query object.""" + return + + @abstractmethod + def make_models_response(self, models: list): + """Return a serialized set of model instances. + + Keyword arguments: + models (list): List of model class instances. + """ + return + + @abstractmethod + def make_model_response(self, model): + """Return a serialized model instance. + + Keyword arguments: + model: Model class instance. + """ + return + + @abstractmethod + def make_collection_response(self, **filters): + """Return a collection query response type. + + Keyword arguments: + filters (dict): Column, value filter options. + """ + return + + @abstractmethod + def make_single_object_response(self, id, **filters): + """Return a single-object query response type. + + Keyword arguments: + id: Unique model identifier. + filters (dict): Column, value filter options. + """ + return + + +def get_adapter(name: str) -> Adapter: + """Return the requested class or raise. + + Keyword arguments: + name (str): Adapter class name. + """ + return _AdapterRegistry.adapters[name] diff --git a/adaptable/extensions.py b/adaptable/extensions.py new file mode 100644 index 0000000..df0c114 --- /dev/null +++ b/adaptable/extensions.py @@ -0,0 +1,142 @@ +"""Adapter extensions. + +Extensions allow for out-of-the-box compatibility with your API +specification of choice. + +Currently supported formats: + - JSONAPI (http://jsonapi.org) +""" +from abc import abstractmethod + +from jsonapiquery import JSONAPIQuery +from jsonapiquery.database.sqlalchemy import group_and_remove + +from adaptable import Adapter + + +class JSONAPIAdapter(Adapter): + """JSONAPI adapter extension. + + Attributes: + query_options (dict): Option, boolean pairs. + """ + + query_options = {} + """JSONAPI query options. + + Setting the "can_filter", "can_sort", "can_paginate", and "can_compound" + keys with a value of `False` will prevent the adapter from allowing those + behaviors to occur. + + "can_*" values default to True but they can specified explicitly if + required. + """ + + def __init__(self, base_url='', parameters={}): + """Initialize the adapter class. + + Keyword arguments: + base_url (str): Request URL stripped of its query parameters. + parameters (dict): Parameter, value pairs. + """ + self.base_url = base_url + self.parameters = parameters + + def make_jsonapi(self, model, view) -> JSONAPIQuery: + """Return a JSONAPI instance. + + Keyword arguments: + model (object): Model object type. + view (object): Schema object type. + """ + return JSONAPIQuery(self.parameters, model, view) + + @abstractmethod + def make_jsonapi_errors(self, errors: list): + """Return a JSONAPI error class instance. + + Keyword arguments: + errors (list): A list of JSONAPI errors. + """ + return + + def make_models_response(self, models: list) -> dict: + """Return a set of JSONAPI serialized models. + + Keyword arguments: + models (list): List of model class instances. + """ + return {'data': self.serialize_all(models)} + + def make_model_response(self, model) -> dict: + """Return a JSONAPI serialized model. + + Keyword arguments: + model: Model class instance. + """ + return {'data': self.serialize(model)} + + def make_collection_response(self, **kwargs) -> dict: + """Return a JSONAPI formatted collection response. + + If configured through the "query_options" attribute, this + method may filter, sort, compound and paginate the document. + + Keyword arguments: + kwargs (dict): Dictionary of query restrictions. + """ + jsonapi = self.make_jsonapi(self.model, self.view) + query, total, selects, schemas = jsonapi.make_query( + self.make_query(**kwargs), self.query_options) + + models = self.fetch_all(query) + models = group_and_remove(models, selects + [self.model]) + + response = self.make_models_response(models.pop()) + response = jsonapi.make_included_response(response, models, schemas) + response = jsonapi.make_paginated_response( + response, self.base_url, total) + return response + + def make_single_object_response(self, id, **kwargs) -> dict: + """Return a JSONAPI formatted response object. + + If configured through the "query_options" attribute, the + document may be compounded. + + Keyword arguments: + id: Unique model identifier. + kwargs (dict): Dictionary of query restrictions. + """ + model, includes, schemas = self.fetch_compounded(id, **kwargs) + jsonapi = self.make_jsonapi(self.model, self.view) + + response = self.make_model_response(model) + response = jsonapi.make_included_response(response, includes, schemas) + return response + + def fetch_compounded(self, id, **kwargs): + """Return a model and the requested joins. + + If configured through the "query_options" attribute, the + query may join and return related models. + + Keyword arguments: + id: Unique model identifier. + kwargs (dict): Dictionary of query restrictions. + """ + query = self.make_query(**kwargs) + jsonapi = self.make_jsonapi(self.model, self.view) + + selects = [] + schemas = [] + if self.query_options.get('can_compound', True): + query, selects, schemas, errors = jsonapi.include(query) + if errors: + raise self.make_jsonapi_errors(errors) + + result = self.fetch_one(query, id) + if isinstance(result, tuple): + includes = group_and_remove([result[1:]], selects) + return result[0], includes, schemas + return result, [], [] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8f2f4d8 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +"""adaptable. + +A serialization middleware. +""" +from setuptools import find_packages, setup + + +setup( + name='adaptable', + version='0.1', + url='https://github.com/caxiam/adaptable', + license='Apache Version 2.0', + author='Colton Allen', + author_email='colton.allen@caxiam.com', + description='A convention driven approach to serialization.', + long_description=__doc__, + packages=find_packages(exclude=("tests*", "docs*")), + package_dir={'adaptable': 'adaptable'}, + zip_safe=False, + include_package_data=True, + platforms='any', + install_requires=[], + classifiers=[ + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Software Development :: Libraries :: Python Modules' + ], + test_suite='tests' +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/jsonapi.py b/tests/jsonapi.py new file mode 100644 index 0000000..6de4c86 --- /dev/null +++ b/tests/jsonapi.py @@ -0,0 +1,25 @@ +from jsonapiquery import JSONAPIQuery +from jsonapiquery.drivers.model import SQLAlchemyDriver +from jsonapiquery.drivers.view import MarshmallowDriver + + +class JSONAPI(JSONAPIQuery): + model_driver = SQLAlchemyDriver + view_driver = MarshmallowDriver + + def make_errors(self, errors): + """Return an "Exception" instance.""" + return Exception(errors) + + def serialize_included(self, schema, models): + """Return serialized output. + + For included responses, we only want to include "id" and "type" + fields. This method of inclusion is obviously contrived. More + flexibility can be found by offloading this hard coded behavior + to the schema. By using a request context, in the case of flask, + the method can key off the request URL and return an adapter + based on the API being accessed. + """ + adapter = schema.get_adapter(simplified=True)() + return adapter.serialize_all(models) diff --git a/tests/marshmallow.py b/tests/marshmallow.py new file mode 100644 index 0000000..fd1812b --- /dev/null +++ b/tests/marshmallow.py @@ -0,0 +1,27 @@ +from adaptable import get_adapter +from marshmallow_jsonapi import Schema, fields + + +class UserSchema(Schema): + id = fields.Integer() + name = fields.String() + parent = fields.Relationship( + include_resource_linkage=True, type_='users', schema='UserSchema') + + class Meta: + type_ = 'users' + + def get_adapter(self, simplified=False): + """Return an adapter object. + + This method of retrieval is contrived. In a real world use case, + you might want to return an adapter based on the URL requested. + Different APIs or different API versions could return + different fields from one another. By coupling to something like + a flask request context, this method can be converted to a + property and intelligently return based on the conditions of the + request. + """ + if simplified: + return get_adapter('SimplifiedResponseAdapter') + return get_adapter('UserAdapter') diff --git a/tests/sqlalchemy.py b/tests/sqlalchemy.py new file mode 100644 index 0000000..d3d8c72 --- /dev/null +++ b/tests/sqlalchemy.py @@ -0,0 +1,67 @@ +from sqlalchemy import create_engine, event, ForeignKey +from sqlalchemy import Column, Integer, String +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship + + +Base = declarative_base() + + +class UserModel(Base): + __tablename__ = 'user' + + id = Column(Integer, primary_key=True) + name = Column(String) + parent_id = Column(Integer, ForeignKey('user.id')) + + parent = relationship( + 'UserModel', backref='children', remote_side=[id], uselist=False) + + +def make_engine(): + """Return an engine instance.""" + return create_engine('sqlite://') + + +def make_session(engine): + """Return a SQLAlchemy session factory.""" + return sessionmaker(bind=engine)() + + +class SQLAlchemyTestMixin(object): + + def setUp(self): + """Create a save point and start the session.""" + self.engine = engine + self.session = self.make_session() + self.session.begin_nested() + + def make_session(self): + return make_session(self.engine) + + def tearDown(self): + """Close the session and rollback to the previous save point.""" + self.session.rollback() + self.session.close() + + @classmethod + def setUpClass(cls): + """Create the database.""" + global engine + + engine = make_engine() + + @event.listens_for(engine, "connect") + def do_connect(dbapi_connection, connection_record): + dbapi_connection.isolation_level = None + + @event.listens_for(engine, "begin") + def do_begin(conn): + conn.execute("BEGIN") + + Base.metadata.create_all(engine) + + @classmethod + def tearDownClass(cls): + """Destroy the database.""" + engine.dispose() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..fb344ba --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,5 @@ +from unittest import TestCase + + +class UnitTestCase(TestCase): + pass diff --git a/tests/unit/adapter_tests.py b/tests/unit/adapter_tests.py new file mode 100644 index 0000000..109797f --- /dev/null +++ b/tests/unit/adapter_tests.py @@ -0,0 +1,22 @@ +from adaptable import Adapter, get_adapter +from tests.unit import UnitTestCase + + +class BaseAdapter(Adapter): + pass + + +class AdapterUnitTestCase(UnitTestCase): + + def test_adapter_registry(self): + """Assert the test adapter has been registered.""" + self.assertTrue(get_adapter('BaseAdapter') == BaseAdapter) + + def test_adapter_registry_invalid_key(self): + """Assert missing adapters raise a KeyError.""" + try: + get_adapter('XYZ') + except KeyError: + self.assertTrue(True) + else: + self.assertTrue(False) diff --git a/tests/unit/jsonapi_tests.py b/tests/unit/jsonapi_tests.py new file mode 100644 index 0000000..4015af2 --- /dev/null +++ b/tests/unit/jsonapi_tests.py @@ -0,0 +1,106 @@ +from sqlalchemy.orm import Query, sessionmaker +from jsonapiquery.database.sqlalchemy import QueryMixin + +from adaptable.extensions import JSONAPIAdapter +from tests.unit import UnitTestCase +from tests.jsonapi import JSONAPI +from tests.marshmallow import UserSchema +from tests.sqlalchemy import SQLAlchemyTestMixin, UserModel + + +def make_session(engine): + class BaseQuery(QueryMixin, Query): + pass + return sessionmaker(bind=engine, query_cls=BaseQuery)() + + +class UserAdapter(JSONAPIAdapter): + fields = ('id', 'name', 'parent') + model = UserModel + view = UserSchema + + def deserialize(self, form): + result, errors = self.view(only=self.fields).load(form) + if errors: + raise Exception(errors) + return result + + def serialize(self, model): + return self.view(only=self.fields).dump(model).data['data'] + + def fetch_all(self, query): + return query.all() + + def fetch_one(self, query, id): + return query.filter(self.model.id == id).first() + + def make_jsonapi(self, model, view): + return JSONAPI(self.parameters, model, view) + + def make_query(self): + return session.query(self.model) + + +class SimplifiedResponseAdapter(UserAdapter): + fields = ('id',) + + +class JSONAPIUnitTestCase(SQLAlchemyTestMixin, UnitTestCase): + + def make_session(self): + global session + + session = make_session(self.engine) + return session + + def test_serialize_collection(self): + """Assert a fully marshaled collection is returned.""" + model = UserModel() + self.session.add(model) + + adapter = UserAdapter() + response = adapter.make_collection_response() + print(response) + self.assertTrue('data' in response) + self.assertTrue(isinstance(response['data'], list)) + self.assertTrue('id' in response['data'][0]) + self.assertTrue('type' in response['data'][0]) + self.assertTrue('name' in response['data'][0]['attributes']) + self.assertTrue('parent' in response['data'][0]['relationships']) + self.assertTrue(len(response['data'][0]) == 4) + + def test_serialize_response(self): + """Assert a fully marshaled response is returned.""" + model = UserModel() + self.session.add(model) + + adapter = UserAdapter() + response = adapter.make_single_object_response(1) + self.assertTrue('data' in response) + self.assertTrue('id' in response['data']) + self.assertTrue('type' in response['data']) + self.assertTrue('name' in response['data']['attributes']) + self.assertTrue('parent' in response['data']['relationships']) + self.assertTrue(len(response['data']) == 4) + + def test_serialize_include(self): + """Assert a partially marshaling response is included.""" + model = UserModel() + self.session.add(model) + model = UserModel(parent=model) + self.session.add(model) + + adapter = UserAdapter(parameters={'include': 'parent'}) + response = adapter.make_single_object_response(1) + + self.assertTrue('data' in response) + self.assertTrue('id' in response['data']) + self.assertTrue('type' in response['data']) + self.assertTrue('name' in response['data']['attributes']) + self.assertTrue('parent' in response['data']['relationships']) + self.assertTrue(len(response['data']) == 4) + + self.assertTrue('included' in response) + self.assertTrue('id' in response['included'][0]) + self.assertTrue('type' in response['included'][0]) + self.assertTrue(len(response['included'][0]) == 2)