API with DB
Change-Id: Iedf19086d7e0fde059cfbaedc92b8ac1ff297cfc
This commit is contained in:
parent
c76114ea23
commit
88a24238fa
@ -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
|
||||
|
11
alembic.ini
11
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]
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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')
|
||||
|
@ -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
70
refstack/backend.py
Normal 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
|
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
0
refstack/tests/unit/__init__.py
Normal file
0
refstack/tests/unit/__init__.py
Normal file
24
refstack/tests/unit/tests.py
Normal file
24
refstack/tests/unit/tests.py
Normal 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()
|
@ -4,3 +4,4 @@ flake8==2.0
|
||||
python-subunit>=0.0.18
|
||||
testrepository>=0.0.18
|
||||
testtools>=0.9.34
|
||||
mysqlclient
|
12
tox.ini
12
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
|
||||
|
Loading…
Reference in New Issue
Block a user