diff --git a/.testr.conf b/.testr.conf index bd99ad88..5b3d5441 100644 --- a/.testr.conf +++ b/.testr.conf @@ -1,4 +1,4 @@ [DEFAULT] -test_command=OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 ${PYTHON:-python} -m subunit.run discover -t ./refstack -s ./refstack/tests $LISTOPT $IDOPTION +test_command=OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 ${PYTHON:-python} -m subunit.run discover -t ./refstack -s ./refstack/tests/unit $LISTOPT $IDOPTION test_id_option=--load-list $IDFILE test_list_option=--list diff --git a/alembic.ini b/alembic.ini index ee7b8a37..0692d945 100644 --- a/alembic.ini +++ b/alembic.ini @@ -11,15 +11,8 @@ script_location = alembic # the 'revision' command, regardless of autogenerate # revision_environment = false -#sqlalchemy.url = driver://user:pass@localhost/dbname -sqlalchemy.url = sqlite:///db.sqlite - -[alembic_sqlite] -# path to migration scripts -script_location = alembic - -sqlalchemy.url = sqlite:///db.sqlite - +#sqlalchemy.url = driver://user:pass@127.0.0.1/dbname +sqlalchemy.url = mysql://root:r00t@127.0.0.1/refstack # Logging configuration [loggers] diff --git a/alembic/env.py b/alembic/env.py index 3d6975de..e15adc8d 100755 --- a/alembic/env.py +++ b/alembic/env.py @@ -19,16 +19,11 @@ import sys sys.path.append("./") from alembic import context from sqlalchemy import engine_from_config, pool -from logging.config import fileConfig # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) - # add your model's MetaData object here # for 'autogenerate' support from refstack.models import Base diff --git a/alembic/versions/42278d6179b9_init.py b/alembic/versions/42278d6179b9_init.py index 7096adce..a24fe1d1 100644 --- a/alembic/versions/42278d6179b9_init.py +++ b/alembic/versions/42278d6179b9_init.py @@ -19,6 +19,7 @@ def upgrade(): op.create_table( 'test', sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('cpid', sa.String(length=128), nullable=False), sa.Column('duration_seconds', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id') @@ -37,7 +38,7 @@ def upgrade(): 'results', sa.Column('_id', sa.Integer(), nullable=False), sa.Column('test_id', sa.String(length=36), nullable=False), - sa.Column('name', sa.String(length=1024), nullable=True), + sa.Column('name', sa.String(length=512), nullable=True), sa.Column('uid', sa.String(length=36), nullable=True), sa.ForeignKeyConstraint(['test_id'], ['test.id'], ), sa.PrimaryKeyConstraint('_id'), diff --git a/refstack/api/app.py b/refstack/api/app.py index b9a7f783..202354bb 100644 --- a/refstack/api/app.py +++ b/refstack/api/app.py @@ -13,15 +13,81 @@ # License for the specific language governing permissions and limitations # under the License. -from pecan import make_app +"""App factory.""" + +import json +import logging + +import pecan +from pecan import hooks +import webob + +from refstack import backend + +logger = logging.getLogger(__name__) + + +class BackendHook(hooks.PecanHook): + + """Pecan Hook for providing backend functionality.""" + + def __init__(self, app_config): + """Hook init.""" + self.global_backend = backend.Backend(app_config) + + def before(self, state): + """Before request.""" + state.request.backend = self.global_backend.create_local() + + def after(self, state): + """After request.""" + pass + + +class JSONErrorHook(hooks.PecanHook): + """ + A pecan hook that translates webob HTTP errors into a JSON format. + """ + def __init__(self, app_config): + """Hook init.""" + self.debug = app_config.get('debug', False) + + def on_error(self, state, exc): + """Request error handler.""" + if isinstance(exc, webob.exc.HTTPError): + body = {'code': exc.status_int, + 'title': exc.title} + if self.debug: + body['detail'] = str(exc) + return webob.Response( + body=json.dumps(body), + status=exc.status, + content_type='application/json' + ) + else: + logger.exception(exc) + body = {'code': 500, + 'title': 'Internal Server Error'} + if self.debug: + body['detail'] = str(exc) + return webob.Response( + body=json.dumps(body), + status=500, + content_type='application/json' + ) def setup_app(config): - + """App factory.""" app_conf = dict(config.app) - return make_app( + app = pecan.make_app( app_conf.pop('root'), logging=getattr(config, 'logging', {}), + hooks=[JSONErrorHook(app_conf), hooks.RequestViewerHook( + {'items': ['status', 'method', 'controller', 'path', 'body']} + ), BackendHook(app_conf)], **app_conf ) + + return app diff --git a/refstack/api/config.py b/refstack/api/config.py index 5904e332..801a5ac0 100644 --- a/refstack/api/config.py +++ b/refstack/api/config.py @@ -33,6 +33,7 @@ server = { app = { 'root': 'refstack.api.controllers.root.RootController', 'modules': ['refstack.api'], + 'db_url': 'mysql://root:r00t@127.0.0.1/refstack', 'static_root': '%(confdir)s/public', 'template_path': '%(confdir)s/${package}/templates', 'debug': False, diff --git a/refstack/api/controllers/root.py b/refstack/api/controllers/root.py index ec414444..28d6219b 100644 --- a/refstack/api/controllers/root.py +++ b/refstack/api/controllers/root.py @@ -13,6 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. +"""Root controller.""" + from pecan import expose from refstack.api.controllers import v1 @@ -20,6 +22,8 @@ from refstack.api.controllers import v1 class RootController(object): + """root handler.""" + v1 = v1.V1Controller() @expose('json') diff --git a/refstack/api/controllers/v1.py b/refstack/api/controllers/v1.py index 9b9fe4ca..a18a2d54 100644 --- a/refstack/api/controllers/v1.py +++ b/refstack/api/controllers/v1.py @@ -13,20 +13,40 @@ # License for the specific language governing permissions and limitations # under the License. -"""Version 1 of the API. -""" -from pecan import expose +"""Version 1 of the API.""" +import logging + +import pecan from pecan import rest +logger = logging.getLogger(__name__) + class ResultsController(rest.RestController): - @expose('json') - def index(self): - return {'Results': 'OK'} + """/v1/results handler.""" + + @pecan.expose('json') + def get(self, ): + """GET handler.""" + return {'Result': 'Ok'} + + @pecan.expose(template='json') + def post(self, ): + """POST handler.""" + try: + results = pecan.request.json + except ValueError: + return pecan.abort(400, + detail='Request body \'%s\' could not ' + 'be decoded as JSON.' + '' % pecan.request.body) + test_id = pecan.request.backend.store_results(results) + return {'test_id': test_id} class V1Controller(object): + """Version 1 API controller root.""" results = ResultsController() diff --git a/refstack/backend.py b/refstack/backend.py new file mode 100644 index 00000000..87d49b63 --- /dev/null +++ b/refstack/backend.py @@ -0,0 +1,70 @@ +# +# 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. + +"""Backend provider.""" + +import logging +import uuid + +import sqlalchemy as sa +from sqlalchemy import orm + +from refstack import models + +logger = logging.getLogger(__name__) + + +class Backend(object): + + """Global backend provider.""" + + def __init__(self, app_config): + """Backend factory.""" + engine = sa.create_engine(app_config['db_url']) + self.session_maker = orm.sessionmaker() + self.session_maker.configure(bind=engine) + + def create_local(self): + """Create request-local Backend instance.""" + return LocalBackend(self) + + +class LocalBackend(object): + + """Request-local backend provider.""" + + def __init__(self, global_backend): + """Request-local backend instance.""" + self.db_session = global_backend.session_maker() + + def store_results(self, results): + """Storing results into database. + + :param results: Dict describes test results. + """ + session = self.db_session + + test_id = str(uuid.uuid4()) + test = models.Test(id=test_id, cpid=results.get('cpid'), + duration_seconds=results.get('duration_seconds')) + test_results = results.get('results', []) + for result in test_results: + session.add(models.TestResults( + test_id=test_id, name=result['name'], + uid=result.get('uid', None) + )) + session.add(test) + session.commit() + return test_id diff --git a/refstack/models.py b/refstack/models.py index d283688c..efd900db 100755 --- a/refstack/models.py +++ b/refstack/models.py @@ -14,6 +14,10 @@ # License for the specific language governing permissions and limitations # under the License. +"""DB models""" + +import datetime + import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declarative_base @@ -22,9 +26,14 @@ Base = declarative_base() class Test(Base): + + """Test.""" + __tablename__ = 'test' id = sa.Column(sa.String(36), primary_key=True) + created_at = sa.Column(sa.DateTime(), default=datetime.datetime.utcnow, + nullable=False) cpid = sa.Column(sa.String(128), index=True, nullable=False) duration_seconds = sa.Column(sa.Integer, nullable=False) results = orm.relationship('TestResults', backref='test') @@ -32,6 +41,9 @@ class Test(Base): class TestResults(Base): + + """Test results.""" + __tablename__ = 'results' __table_args__ = ( sa.UniqueConstraint('test_id', 'name'), @@ -40,11 +52,14 @@ class TestResults(Base): _id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) test_id = sa.Column(sa.String(36), sa.ForeignKey('test.id'), index=True, nullable=False, unique=False) - name = sa.Column(sa.String(1024)) + name = sa.Column(sa.String(512)) uid = sa.Column(sa.String(36)) class TestMeta(Base): + + """Test metadata.""" + __tablename__ = 'meta' __table_args__ = ( sa.UniqueConstraint('test_id', 'meta_key'), diff --git a/refstack/tests/api/__init__.py b/refstack/tests/api/__init__.py index 7b1a6574..91591da2 100644 --- a/refstack/tests/api/__init__.py +++ b/refstack/tests/api/__init__.py @@ -13,36 +13,96 @@ # License for the specific language governing permissions and limitations # under the License. -"""Base classes for API tests. -""" -from unittest import TestCase +"""Base classes for API tests.""" +import inspect +import os + +import alembic +import alembic.config from pecan import set_config from pecan.testing import load_test_app +import sqlalchemy as sa +import sqlalchemy.exc +from unittest import TestCase + +import refstack +from refstack.models import Base class FunctionalTest(TestCase): - """ - Used for functional tests where you need to test your + + """Functional test case. + + Used for functional tests where you need to test your. literal application and its integration with the framework. """ def setUp(self): + """Test setup.""" self.config = { 'app': { 'root': 'refstack.api.controllers.root.RootController', + 'db_url': os.environ.get( + 'TEST_DB_URL', + 'mysql://root:r00t@127.0.0.1/refstack_test' + ), 'modules': ['refstack.api'], 'static_root': '%(confdir)s/public', 'template_path': '%(confdir)s/${package}/templates', } } + self.project_path = os.path.abspath( + os.path.join(inspect.getabsfile(refstack), '..', '..')) + + self.prepare_test_db() + self.migrate_test_db() + self.app = load_test_app(self.config) def tearDown(self): + """Test teardown.""" set_config({}, overwrite=True) + def prepare_test_db(self): + """Create/clear test database.""" + db_url = self.config['app']['db_url'] + db_name = db_url.split('/')[-1] + short_db_url = '/'.join(db_url.split('/')[0:-1]) + try: + engine = sa.create_engine(db_url) + conn = engine.connect() + conn.execute('commit') + conn.execute('drop database %s' % db_name) + conn.close() + except sqlalchemy.exc.OperationalError: + pass + finally: + engine = sa.create_engine('/'.join((short_db_url, 'mysql'))) + conn = engine.connect() + conn.execute('commit') + conn.execute('create database %s' % db_name) + conn.close() + + engine = sa.create_engine(db_url) + conn = engine.connect() + conn.execute('commit') + for tbl in reversed(Base.metadata.sorted_tables): + if engine.has_table(tbl.name): + conn.execute('drop table %s' % tbl.name) + conn.close() + + def migrate_test_db(self): + """Apply migrations to test database.""" + alembic_cfg = alembic.config.Config() + alembic_cfg.set_main_option("script_location", + os.path.join(self.project_path, 'alembic')) + alembic_cfg.set_main_option("sqlalchemy.url", + self.config['app']['db_url']) + alembic.command.upgrade(alembic_cfg, 'head') + def get_json(self, url, headers=None, extra_environ=None, status=None, expect_errors=False, **params): - """Sends HTTP GET request. + """Send HTTP GET request. :param url: url path to target service :param headers: a dictionary of extra headers to send @@ -70,3 +130,36 @@ class FunctionalTest(TestCase): if not expect_errors: response = response.json return response + + def post_json(self, url, headers=None, extra_environ=None, + status=None, expect_errors=False, + content_type='application/json', **params): + """Send HTTP POST request. + + :param url: url path to target service + :param headers: a dictionary of extra headers to send + :param extra_environ: a dictionary of environmental variables that + should be added to the request + :param status: integer or string of the HTTP status code you expect + in response (if not 200 or 3xx). You can also use a + wildcard, like '3*' or '*' + :param expect_errors: boolean value, if this is False, then if + anything is written to environ wsgi.errors it + will be an error. If it is True, then + non-200/3xx responses are also okay + :param params: a query string, or a dictionary that will be encoded + into a query string. You may also include a URL query + string on the url + + """ + response = self.app.post(url, + headers=headers, + extra_environ=extra_environ, + status=status, + expect_errors=expect_errors, + content_type=content_type, + **params) + + if not expect_errors: + response = response.json + return response diff --git a/refstack/tests/api/test_api.py b/refstack/tests/api/test_api.py index 9f98f87a..5b0b2ca1 100644 --- a/refstack/tests/api/test_api.py +++ b/refstack/tests/api/test_api.py @@ -12,6 +12,8 @@ # 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 json +import uuid from refstack.tests import api @@ -19,11 +21,25 @@ from refstack.tests import api class TestRefStackApi(api.FunctionalTest): def test_root_controller(self): + """Test request to root.""" actual_response = self.get_json('/') expected_response = {'Root': 'OK'} self.assertEqual(expected_response, actual_response) def test_results_controller(self): - actual_response = self.get_json('/v1/results/') - expected_response = {'Results': 'OK'} - self.assertEqual(expected_response, actual_response) + """Test results endpoint.""" + results = json.dumps({ + 'cpid': 'foo', + 'duration_seconds': 10, + 'results': [ + {'name': 'tempest.foo.bar'}, + {'name': 'tempest.buzz', + 'uid': '42'} + ] + }) + actual_response = self.post_json('/v1/results/', params=results) + self.assertIn('test_id', actual_response) + try: + uuid.UUID(actual_response.get('test_id'), version=4) + except ValueError: + self.fail("actual_response doesn't contain test_is") diff --git a/refstack/tests/unit/__init__.py b/refstack/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/refstack/tests/unit/tests.py b/refstack/tests/unit/tests.py new file mode 100644 index 00000000..2b2202f2 --- /dev/null +++ b/refstack/tests/unit/tests.py @@ -0,0 +1,24 @@ +# +# 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 unittest + + +class TestSequenceFunctions(unittest.TestCase): + + def test_nothing(self): + # make sure the shuffled sequence does not lose any elements + pass + +if __name__ == '__main__': + unittest.main() diff --git a/test-requirements.txt b/test-requirements.txt index f897c0c5..c508dde5 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,4 @@ flake8==2.0 python-subunit>=0.0.18 testrepository>=0.0.18 testtools>=0.9.34 +mysqlclient \ No newline at end of file diff --git a/tox.ini b/tox.ini index f5ba17fe..f29a4aa6 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,18 @@ deps = -r{toxinidir}/requirements.txt commands = python setup.py testr --testr-args='{posargs}' distribute = false +[testenv:func] +usedevelop = True +install_command = pip install -U {opts} {packages} +setenv = VIRTUAL_ENV={envdir} + LANG=en_US.UTF-8 + LANGUAGE=en_US:en + LC_ALL=C +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = python -m unittest discover ./refstack/tests/api +distribute = false + [testenv:pep8] commands = flake8 distribute = false