From 5048d8380c9ff34f98fc944677b0e1dc49329616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Miedzi=C5=84ski?= Date: Mon, 30 Nov 2015 11:26:49 +0100 Subject: [PATCH] Add SQLAlchemy storage --- docs/source/oauth2client.contrib.rst | 1 + .../oauth2client.contrib.sqlalchemy.rst | 7 + oauth2client/contrib/sqlalchemy.py | 170 ++++++++++++++++++ tests/contrib/test_sqlalchemy.py | 118 ++++++++++++ tox.ini | 1 + 5 files changed, 297 insertions(+) create mode 100644 docs/source/oauth2client.contrib.sqlalchemy.rst create mode 100644 oauth2client/contrib/sqlalchemy.py create mode 100644 tests/contrib/test_sqlalchemy.py diff --git a/docs/source/oauth2client.contrib.rst b/docs/source/oauth2client.contrib.rst index 0caaa29..d926c76 100644 --- a/docs/source/oauth2client.contrib.rst +++ b/docs/source/oauth2client.contrib.rst @@ -22,6 +22,7 @@ Submodules oauth2client.contrib.keyring_storage oauth2client.contrib.locked_file oauth2client.contrib.multistore_file + oauth2client.contrib.sqlalchemy oauth2client.contrib.xsrfutil Module contents diff --git a/docs/source/oauth2client.contrib.sqlalchemy.rst b/docs/source/oauth2client.contrib.sqlalchemy.rst new file mode 100644 index 0000000..94eeeec --- /dev/null +++ b/docs/source/oauth2client.contrib.sqlalchemy.rst @@ -0,0 +1,7 @@ +oauth2client.contrib.sqlalchemy module +====================================== + +.. automodule:: oauth2client.contrib.sqlalchemy + :members: + :undoc-members: + :show-inheritance: diff --git a/oauth2client/contrib/sqlalchemy.py b/oauth2client/contrib/sqlalchemy.py new file mode 100644 index 0000000..4c62a06 --- /dev/null +++ b/oauth2client/contrib/sqlalchemy.py @@ -0,0 +1,170 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OAuth 2.0 utilities for SQLAlchemy. + +Utilities for using OAuth 2.0 in conjunction with a SQLAlchemy. + +Configuration +============= + +In order to use this storage, you'll need to create table +with :class:`oauth2client.contrib.sql_alchemy.CredentialsType` column. +It's recommended to either put this column on some sort of user info +table or put the column in a table with a belongs-to relationship to +a user info table. + +Here's an example of a simple table with a :class:`CredentialsType` +column that's related to a user table by the `user_id` key. + +.. code-block:: python + + from oauth2client.contrib.sql_alchemy import CredentialsType + from sqlalchemy import Column, ForeignKey, Integer + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import relationship + + + Base = declarative_base() + + + class Credentials(Base): + __tablename__ = 'credentials' + + user_id = Column(Integer, ForeignKey('user.id')) + credentials = Column(CredentialsType) + + + class User(Base): + id = Column(Integer, primary_key=True) + # bunch of other columns + credentials = relationship('Credentials') + + +Usage +===== + +With tables ready, you are now able to store credentials in database. +We will reuse tables defined above. + +.. code-block:: python + + from oauth2client.client import OAuth2Credentials + from oauth2client.contrib.sql_alchemy import Storage + from sqlalchemy.orm import Session + + session = Session() + user = session.query(User).first() + storage = Storage( + session=session, + model_class=Credentials, + # This is the key column used to identify + # the row that stores the credentials. + key_name='user_id', + key_value=user.id, + property_name='credentials', + ) + + # Store + credentials = OAuth2Credentials(...) + storage.put(credentials) + + # Retrieve + credentials = storage.get() + + # Delete + storage.delete() + +""" + +from __future__ import absolute_import + +import oauth2client.client +import sqlalchemy.types + + +class CredentialsType(sqlalchemy.types.PickleType): + """Type representing credentials. + + Alias for :class:`sqlalchemy.types.PickleType`. + """ + + +class Storage(oauth2client.client.Storage): + """Store and retrieve a single credential to and from SQLAlchemy. + This helper presumes the Credentials + have been stored as a Credentials column + on a db model class. + """ + + def __init__(self, session, model_class, key_name, + key_value, property_name): + """Constructor for Storage. + + Args: + session: An instance of :class:`sqlalchemy.orm.Session`. + model_class: SQLAlchemy declarative mapping. + key_name: string, key name for the entity that has the credentials + key_value: key value for the entity that has the credentials + property_name: A string indicating which property on the + ``model_class`` to store the credentials. + This property must be a + :class:`CredentialsType` column. + """ + super(Storage, self).__init__() + + self.session = session + self.model_class = model_class + self.key_name = key_name + self.key_value = key_value + self.property_name = property_name + + def locked_get(self): + """Retrieve stored credential. + + Returns: + A :class:`oauth2client.Credentials` instance or `None`. + """ + filters = {self.key_name: self.key_value} + query = self.session.query(self.model_class).filter_by(**filters) + entity = query.first() + + if entity: + credential = getattr(entity, self.property_name) + if credential and hasattr(credential, 'set_store'): + credential.set_store(self) + return credential + else: + return None + + def locked_put(self, credentials): + """Write a credentials to the SQLAlchemy datastore. + + Args: + credentials: :class:`oauth2client.Credentials` + """ + filters = {self.key_name: self.key_value} + query = self.session.query(self.model_class).filter_by(**filters) + entity = query.first() + + if not entity: + entity = self.model_class(**filters) + + setattr(entity, self.property_name, credentials) + self.session.add(entity) + + def locked_delete(self): + """Delete credentials from the SQLAlchemy datastore.""" + filters = {self.key_name: self.key_value} + self.session.query(self.model_class).filter_by(**filters).delete() diff --git a/tests/contrib/test_sqlalchemy.py b/tests/contrib/test_sqlalchemy.py new file mode 100644 index 0000000..8f671a3 --- /dev/null +++ b/tests/contrib/test_sqlalchemy.py @@ -0,0 +1,118 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +import oauth2client +import oauth2client.client +import oauth2client.contrib.sqlalchemy +import sqlalchemy +import sqlalchemy.ext.declarative +import sqlalchemy.orm +import unittest2 + +Base = sqlalchemy.ext.declarative.declarative_base() + + +class DummyModel(Base): + __tablename__ = 'dummy' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + # we will query against this, because of ROWID + key = sqlalchemy.Column(sqlalchemy.Integer) + credentials = sqlalchemy.Column( + oauth2client.contrib.sqlalchemy.CredentialsType) + + +class TestSQLAlchemyStorage(unittest2.TestCase): + def setUp(self): + engine = sqlalchemy.create_engine('sqlite://') + Base.metadata.create_all(engine) + + self.session = sqlalchemy.orm.sessionmaker(bind=engine) + self.credentials = oauth2client.client.OAuth2Credentials( + access_token='token', + client_id='client_id', + client_secret='client_secret', + refresh_token='refresh_token', + token_expiry=datetime.datetime.utcnow(), + token_uri=oauth2client.GOOGLE_TOKEN_URI, + user_agent='DummyAgent', + ) + + def tearDown(self): + session = self.session() + session.query(DummyModel).filter_by(key=1).delete() + session.commit() + + def compare_credentials(self, result): + self.assertEqual(result.access_token, self.credentials.access_token) + self.assertEqual(result.client_id, self.credentials.client_id) + self.assertEqual(result.client_secret, self.credentials.client_secret) + self.assertEqual(result.refresh_token, self.credentials.refresh_token) + self.assertEqual(result.token_expiry, self.credentials.token_expiry) + self.assertEqual(result.token_uri, self.credentials.token_uri) + self.assertEqual(result.user_agent, self.credentials.user_agent) + + def test_get(self): + session = self.session() + session.add(DummyModel( + key=1, + credentials=self.credentials, + )) + session.commit() + + credentials = oauth2client.contrib.sqlalchemy.Storage( + session=session, + model_class=DummyModel, + key_name='key', + key_value=1, + property_name='credentials', + ).get() + + self.compare_credentials(credentials) + + def test_put(self): + session = self.session() + oauth2client.contrib.sqlalchemy.Storage( + session=session, + model_class=DummyModel, + key_name='key', + key_value=1, + property_name='credentials', + ).put(self.credentials) + session.commit() + + entity = session.query(DummyModel).filter_by(key=1).first() + self.compare_credentials(entity.credentials) + + def test_delete(self): + session = self.session() + session.add(DummyModel( + key=1, + credentials=self.credentials, + )) + session.commit() + + query = session.query(DummyModel).filter_by(key=1) + self.assertIsNotNone(query.first()) + oauth2client.contrib.sqlalchemy.Storage( + session=session, + model_class=DummyModel, + key_name='key', + key_value=1, + property_name='credentials', + ).delete() + session.commit() + self.assertIsNone(query.first()) diff --git a/tox.ini b/tox.ini index 8e94c01..ac06854 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ basedeps = mock>=1.3.0 nose flask unittest2 + sqlalchemy deps = {[testenv]basedeps} django keyring