API with DB

Change-Id: Iedf19086d7e0fde059cfbaedc92b8ac1ff297cfc
This commit is contained in:
sslypushenko 2015-01-13 19:37:41 +02:00
parent c76114ea23
commit 88a24238fa
16 changed files with 346 additions and 35 deletions

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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'),

View File

@ -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

View File

@ -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,

View File

@ -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')

View File

@ -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()

70
refstack/backend.py Normal file
View File

@ -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

View File

@ -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'),

View File

@ -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

View File

@ -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")

View File

View File

@ -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()

View File

@ -4,3 +4,4 @@ flake8==2.0
python-subunit>=0.0.18
testrepository>=0.0.18
testtools>=0.9.34
mysqlclient

12
tox.ini
View File

@ -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