Finish retrieving documents by revision_id, including with filters.

This commit is contained in:
Felipe Monteiro 2017-07-30 04:11:32 +01:00
parent fdab717350
commit 8e43f91751
10 changed files with 237 additions and 59 deletions

View File

@ -22,7 +22,9 @@ Document creation can be tested locally using (from root deckhand directory):
.. code-block:: console
curl -i -X POST localhost:9000/api/v1.0/documents \
-H "Content-Type: application/x-yaml" \
--data-binary "@deckhand/tests/unit/resources/sample.yaml"
$ curl -i -X POST localhost:9000/api/v1.0/documents \
-H "Content-Type: application/x-yaml" \
--data-binary "@deckhand/tests/unit/resources/sample.yaml"
# revision_id copy/pasted from previous response.
$ curl -i -X GET localhost:9000/api/v1.0/revisions/0e99c8b9-bab4-4fc7-8405-7dbd22c33a30/documents

View File

@ -23,8 +23,7 @@ from oslo_serialization import jsonutils as json
from deckhand.control import base as api_base
from deckhand.db.sqlalchemy import api as db_api
from deckhand.engine import document_validation
from deckhand import errors as deckhand_errors
from deckhand import errors
LOG = logging.getLogger(__name__)
@ -40,6 +39,12 @@ class RevisionsResource(api_base.BaseResource):
documents will be as originally posted with no substitutions or
layering applied.
"""
revision = db_api.revision_get(revision_id)
resp.status = falcon.HTTP_201
resp.body = revision['documents']
params = req.params
LOG.debug('PARAMS: %s' % params)
try:
documents = db_api.revision_get_documents(revision_id, **params)
except errors.RevisionNotFound as e:
return self.return_error(resp, falcon.HTTP_403, message=e)
resp.status = falcon.HTTP_200
resp.body = json.dumps(documents)

View File

@ -15,6 +15,7 @@
"""Defines interface for DB access."""
import ast
import copy
import datetime
import threading
@ -36,6 +37,8 @@ from sqlalchemy import sql
import sqlalchemy.sql as sa_sql
from deckhand.db.sqlalchemy import models
from deckhand import errors
from deckhand import utils
sa_logger = None
LOG = logging.getLogger(__name__)
@ -190,6 +193,64 @@ def revision_create(session=None):
def revision_get(revision_id, session=None):
"""Return the specified `revision_id`.
:raises: RevisionNotFound if the revision was not found.
"""
session = session or get_session()
revision = session.query(models.Revision).get(revision_id)
return revision.to_dict()
try:
revision = session.query(models.Revision).filter_by(
id=revision_id).one().to_dict()
except sa_orm.exc.NoResultFound:
raise errors.RevisionNotFound(revision=revision_id)
return revision
def revision_get_documents(revision_id, session=None, **filters):
"""Return the documents that match filters for the specified `revision_id`.
:raises: RevisionNotFound if the revision was not found.
"""
session = session or get_session()
try:
revision = session.query(models.Revision).filter_by(
id=revision_id).one().to_dict()
except sa_orm.exc.NoResultFound:
raise errors.RevisionNotFound(revision=revision_id)
filtered_documents = _filter_revision_documents(
revision['documents'], **filters)
return filtered_documents
def _filter_revision_documents(documents, **filters):
"""Return the list of documents that match filters.
:returns: list of documents that match specified filters.
"""
# TODO: Implement this as an sqlalchemy query.
filtered_documents = []
for document in documents:
match = True
for filter_key, filter_val in filters.items():
actual_val = utils.multi_getattr(filter_key, document)
if (isinstance(actual_val, bool)
and isinstance(filter_val, six.text_type)):
try:
filter_val = ast.literal_eval(filter_val.title())
except ValueError:
# If not True/False, set to None to avoid matching
# `actual_val` which is always boolean.
filter_val = None
if actual_val != filter_val:
match = False
if match:
filtered_documents.append(document)
return filtered_documents

View File

@ -98,38 +98,3 @@ class DocumentValidation(object):
except jsonschema.exceptions.ValidationError as e:
raise errors.InvalidFormat('The provided YAML file is invalid. '
'Exception: %s.' % e.message)
def _multi_getattr(self, multi_key, substitutable_data):
"""Iteratively check for nested attributes in the YAML data.
Check for nested attributes included in "dest" attributes in the data
section of the YAML file. For example, a "dest" attribute of
".foo.bar.baz" should mean that the YAML data adheres to:
.. code-block:: yaml
---
foo:
bar:
baz: <data_to_be_substituted_here>
:param multi_key: A multi-part key that references nested data in the
substitutable part of the YAML data, e.g. ".foo.bar.baz".
:param substitutable_data: The section of data in the YAML data that
is intended to be substituted with secrets.
:returns: Tuple where first value is a boolean indicating that the
nested attribute was found and the second value is the attribute
that was not found, if applicable.
"""
attrs = multi_key.split('.')
# Ignore the first attribute if it is "." as that is a self-reference.
if attrs[0] == '':
attrs = attrs[1:]
data = substitutable_data
for attr in attrs:
if attr not in data:
return False, attr
data = data.get(attr)
return True, None

View File

@ -57,3 +57,7 @@ class DocumentExists(DeckhandException):
msg_fmt = ("Document with kind %(kind)s and schemaVersion "
"%(schema_version)s already exists.")
class RevisionNotFound(DeckhandException):
msg_fmt = ("The requested revision %(revision)s was not found.")
code = 403

View File

@ -34,7 +34,7 @@ def rand_uuid_hex():
return uuid.uuid4().hex
def rand_name(name='', prefix='tempest'):
def rand_name(name='', prefix='deckhand'):
"""Generate a random name that includes a random number
:param str name: The name that you want to include
@ -51,3 +51,12 @@ def rand_name(name='', prefix='tempest'):
if prefix:
rand_name = prefix + '-' + rand_name
return rand_name
def rand_bool():
"""Generate a random boolean value.
:return: a random boolean value.
:rtype: boolean
"""
return random.choice([True, False])

View File

@ -18,17 +18,25 @@ import testtools
from deckhand.control import api
from deckhand.control import base as api_base
from deckhand.control import documents
from deckhand.control import revisions
from deckhand.control import secrets
class TestApi(testtools.TestCase):
def setUp(self):
super(TestApi, self).setUp()
for resource in (documents, revisions, secrets):
resource_name = resource.__name__.split('.')[-1]
resource_obj = mock.patch.object(
resource, '%sResource' % resource_name.title()).start()
setattr(self, '%s_resource' % resource_name, resource_obj)
@mock.patch.object(api, 'db_api', autospec=True)
@mock.patch.object(api, 'config', autospec=True)
@mock.patch.object(api, 'secrets', autospec=True)
@mock.patch.object(api, 'documents', autospec=True)
@mock.patch.object(api, 'falcon', autospec=True)
def test_start_api(self, mock_falcon, mock_documents, mock_secrets,
def test_start_api(self, mock_falcon,
mock_config, mock_db_api):
mock_falcon_api = mock_falcon.API.return_value
@ -38,9 +46,10 @@ class TestApi(testtools.TestCase):
mock_falcon.API.assert_called_once_with(
request_type=api_base.DeckhandRequest)
mock_falcon_api.add_route.assert_has_calls([
mock.call(
'/api/v1.0/documents', mock_documents.DocumentsResource()),
mock.call('/api/v1.0/secrets', mock_secrets.SecretsResource())
mock.call('/api/v1.0/documents', self.documents_resource()),
mock.call('/api/v1.0/revisions/{revision_id}/documents',
self.revisions_resource()),
mock.call('/api/v1.0/secrets', self.secrets_resource())
])
mock_config.parse_args.assert_called_once_with()
mock_db_api.setup_db.assert_called_once_with()

View File

@ -12,9 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import mock
import uuid
import testtools
from testtools import matchers
@ -33,9 +30,17 @@ class DocumentFixture(object):
@staticmethod
def get_minimal_fixture(**kwargs):
fixture = {'data': test_utils.rand_name('data'),
'metadata': {'name': test_utils.rand_name('name')},
'schema': test_utils.rand_name('schema', prefix='deckhand')}
fixture = {
'data': test_utils.rand_name('data'),
'metadata': {
'name': test_utils.rand_name('metadata_data'),
'label': test_utils.rand_name('metadata_label'),
'layeringDefinition': {
'abstract': test_utils.rand_bool(),
'layer': test_utils.rand_name('layer')
}
},
'schema': test_utils.rand_name('schema')}
fixture.update(kwargs)
return fixture
@ -45,7 +50,7 @@ class DocumentFixture(object):
for _ in range(count)]
class TestDocumentsApi(base.DeckhandWithDBTestCase):
class TestDocumentsBase(base.DeckhandWithDBTestCase):
def _create_documents(self, payload):
if not isinstance(payload, list):
@ -66,6 +71,12 @@ class TestDocumentsApi(base.DeckhandWithDBTestCase):
self._validate_revision(revision)
return revision
def _get_revision_documents(self, revision_id, **filters):
documents = db_api.revision_get_documents(revision_id, **filters)
for document in documents:
self._validate_document(document)
return documents
def _validate_object(self, obj):
for attr in BASE_EXPECTED_FIELDS:
if attr.endswith('_at'):
@ -98,6 +109,9 @@ class TestDocumentsApi(base.DeckhandWithDBTestCase):
for attr in REVISION_EXPECTED_FIELDS:
self.assertIn(attr, revision)
class TestDocuments(TestDocumentsBase):
def test_create_and_get_document(self):
payload = DocumentFixture.get_minimal_fixture()
documents = self._create_documents(payload)
@ -148,3 +162,21 @@ class TestDocumentsApi(base.DeckhandWithDBTestCase):
revision = self._get_revision(document['revision_id'])
self._validate_revision(revision)
self.assertEqual(document['revision_id'], revision['id'])
def test_get_documents_by_revision_id_and_filters(self):
payload = DocumentFixture.get_minimal_fixture()
document = self._create_documents(payload)[0]
filters = {
'schema': document['schema'],
'metadata.name': document['metadata']['name'],
'metadata.layeringDefinition.abstract':
document['metadata']['layeringDefinition']['abstract'],
'metadata.layeringDefinition.layer':
document['metadata']['layeringDefinition']['layer'],
'metadata.label': document['metadata']['label']
}
documents = self._get_revision_documents(
document['revision_id'], **filters)
self.assertEqual(1, len(documents))
self.assertEqual(document, documents[0])

View File

@ -0,0 +1,44 @@
# Copyright 2017 AT&T Intellectual Property. All other 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 testtools
from deckhand.db.sqlalchemy import api as db_api
from deckhand.tests import test_utils
from deckhand.tests.unit import base
from deckhand.tests.unit.db import test_documents
class TestDocumentsNegative(test_documents.TestDocumentsBase):
def test_get_documents_by_revision_id_and_wrong_filters(self):
payload = test_documents.DocumentFixture.get_minimal_fixture()
document = self._create_documents(payload)[0]
filters = {
'schema': 'fake_schema',
'metadata.name': 'fake_meta_name',
'metadata.layeringDefinition.abstract':
not document['metadata']['layeringDefinition']['abstract'],
'metadata.layeringDefinition.layer': 'fake_layer',
'metadata.label': 'fake_label'
}
documents = self._get_revision_documents(
document['revision_id'], **filters)
self.assertEmpty(documents)
for filter_key, filter_val in filters.items():
documents = self._get_revision_documents(
document['revision_id'], filter_key=filter_val)
self.assertEmpty(documents)

47
deckhand/utils.py Normal file
View File

@ -0,0 +1,47 @@
# Copyright 2017 AT&T Intellectual Property. All other 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.
def multi_getattr(multi_key, dict_data):
"""Iteratively check for nested attributes in the YAML data.
Check for nested attributes included in "dest" attributes in the data
section of the YAML file. For example, a "dest" attribute of
".foo.bar.baz" should mean that the YAML data adheres to:
.. code-block:: yaml
---
foo:
bar:
baz: <data_to_be_substituted_here>
:param multi_key: A multi-part key that references nested data in the
substitutable part of the YAML data, e.g. ".foo.bar.baz".
:param substitutable_data: The section of data in the YAML data that
is intended to be substituted with secrets.
:returns: nested entry in ``dict_data`` if present; else None.
"""
attrs = multi_key.split('.')
# Ignore the first attribute if it is "." as that is a self-reference.
if attrs[0] == '':
attrs = attrs[1:]
data = dict_data
for attr in attrs:
if attr not in data:
return None
data = data.get(attr)
return data