diff --git a/deckhand/control/api.py b/deckhand/control/api.py index 5bb28e18..15d3257f 100644 --- a/deckhand/control/api.py +++ b/deckhand/control/api.py @@ -21,6 +21,7 @@ from oslo_log import log as logging from deckhand.conf import config from deckhand.control import base as api_base from deckhand.control import documents +from deckhand.control import revision_documents from deckhand.control import revisions from deckhand.control import secrets from deckhand.db.sqlalchemy import api as db_api @@ -69,7 +70,9 @@ def start_api(state_manager=None): v1_0_routes = [ ('documents', documents.DocumentsResource()), - ('revisions/{revision_id}/documents', revisions.RevisionsResource()), + ('revisions', revisions.RevisionsResource()), + ('revisions/{revision_id}/documents', + revision_documents.RevisionDocumentsResource()), ('secrets', secrets.SecretsResource()) ] diff --git a/deckhand/control/revision_documents.py b/deckhand/control/revision_documents.py new file mode 100644 index 00000000..22b12b82 --- /dev/null +++ b/deckhand/control/revision_documents.py @@ -0,0 +1,50 @@ +# 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 copy +import yaml + +import falcon + +from oslo_db import exception as db_exc +from oslo_log import log as logging +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 import errors + +LOG = logging.getLogger(__name__) + + +class RevisionDocumentsResource(api_base.BaseResource): + """API resource for realizing CRUD endpoints for Document Revisions.""" + + def on_get(self, req, resp, revision_id): + """Returns all documents for a `revision_id`. + + Returns a multi-document YAML response containing all the documents + matching the filters specified via query string parameters. Returned + documents will be as originally posted with no substitutions or + layering applied. + """ + params = req.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 + # TODO: return YAML-encoded body + resp.body = json.dumps(documents) diff --git a/deckhand/control/revisions.py b/deckhand/control/revisions.py index 77b1fdec..9b6f89ff 100644 --- a/deckhand/control/revisions.py +++ b/deckhand/control/revisions.py @@ -12,39 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -import copy -import yaml - import falcon -from oslo_db import exception as db_exc -from oslo_log import log as logging -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 import errors - -LOG = logging.getLogger(__name__) class RevisionsResource(api_base.BaseResource): """API resource for realizing CRUD endpoints for Document Revisions.""" def on_get(self, req, resp, revision_id): - """Returns all documents for a `revision_id`. + """Returns list of existing revisions. - Returns a multi-document YAML response containing all the documents - matching the filters specified via query string parameters. Returned - documents will be as originally posted with no substitutions or - layering applied. + Lists existing revisions and reports basic details including a summary + of validation status for each `deckhand/ValidationPolicy` that is part + of each revision. """ - 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) + revisions = db_api.revision_get_all() resp.status = falcon.HTTP_200 - resp.body = json.dumps(documents) + resp.body = json.dumps(revisions) diff --git a/deckhand/db/sqlalchemy/api.py b/deckhand/db/sqlalchemy/api.py index 172e16d1..809b87cf 100644 --- a/deckhand/db/sqlalchemy/api.py +++ b/deckhand/db/sqlalchemy/api.py @@ -207,6 +207,20 @@ def revision_get(revision_id, session=None): return revision +def revision_get_all(session=None): + """Return list of all revisions.""" + session = session or get_session() + revisions = session.query(models.Revision).all() + + revisions_resp = [] + for revision in revisions: + revision_dict = revision.to_dict() + revision['count'] = len(revision_dict.pop('documents')) + revisions_resp.append(revision) + + return revisions_resp + + def revision_get_documents(revision_id, session=None, **filters): """Return the documents that match filters for the specified `revision_id`. diff --git a/deckhand/tests/unit/control/test_api.py b/deckhand/tests/unit/control/test_api.py index a5fe4d85..ddbac81c 100644 --- a/deckhand/tests/unit/control/test_api.py +++ b/deckhand/tests/unit/control/test_api.py @@ -19,6 +19,7 @@ import testtools from deckhand.control import api from deckhand.control import base as api_base from deckhand.control import documents +from deckhand.control import revision_documents from deckhand.control import revisions from deckhand.control import secrets @@ -27,10 +28,11 @@ class TestApi(testtools.TestCase): def setUp(self): super(TestApi, self).setUp() - for resource in (documents, revisions, secrets): + for resource in (documents, revisions, revision_documents, secrets): resource_name = resource.__name__.split('.')[-1] resource_obj = mock.patch.object( - resource, '%sResource' % resource_name.title()).start() + resource, '%sResource' % resource_name.title().replace('_', '') + ).start() setattr(self, '%s_resource' % resource_name, resource_obj) @mock.patch.object(api, 'db_api', autospec=True) @@ -47,8 +49,9 @@ class TestApi(testtools.TestCase): request_type=api_base.DeckhandRequest) mock_falcon_api.add_route.assert_has_calls([ mock.call('/api/v1.0/documents', self.documents_resource()), + mock.call('/api/v1.0/revisions', self.revisions_resource()), mock.call('/api/v1.0/revisions/{revision_id}/documents', - self.revisions_resource()), + self.revision_documents_resource()), mock.call('/api/v1.0/secrets', self.secrets_resource()) ]) mock_config.parse_args.assert_called_once_with() diff --git a/deckhand/tests/unit/db/base.py b/deckhand/tests/unit/db/base.py new file mode 100644 index 00000000..e155339f --- /dev/null +++ b/deckhand/tests/unit/db/base.py @@ -0,0 +1,115 @@ +# 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 testtools import matchers + +from deckhand.db.sqlalchemy import api as db_api +from deckhand.tests import test_utils +from deckhand.tests.unit import base + +BASE_EXPECTED_FIELDS = ("created_at", "updated_at", "deleted_at", "deleted") +DOCUMENT_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + ( + "id", "schema", "name", "metadata", "data", "revision_id") +REVISION_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + ( + "id", "child_id", "parent_id", "documents") + + +class DocumentFixture(object): + + @staticmethod + def get_minimal_fixture(**kwargs): + fixture = { + 'data': { + test_utils.rand_name('key'): test_utils.rand_name('value') + }, + '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 + + @staticmethod + def get_minimal_multi_fixture(count=2, **kwargs): + return [DocumentFixture.get_minimal_fixture(**kwargs) + for _ in range(count)] + + +class TestDbBase(base.DeckhandWithDBTestCase): + + def _create_documents(self, payload): + if not isinstance(payload, list): + payload = [payload] + + docs = db_api.documents_create(payload) + for idx, doc in enumerate(docs): + self._validate_document(expected=payload[idx], actual=doc) + return docs + + def _get_document(self, **fields): + doc = db_api.document_get(**fields) + self._validate_document(actual=doc) + return doc + + def _get_revision(self, revision_id): + revision = db_api.revision_get(revision_id) + 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 _list_revisions(self): + return db_api.revision_get_all() + + def _validate_object(self, obj): + for attr in BASE_EXPECTED_FIELDS: + if attr.endswith('_at'): + self.assertThat(obj[attr], matchers.MatchesAny( + matchers.Is(None), matchers.IsInstance(str))) + else: + self.assertIsInstance(obj[attr], bool) + + def _validate_document(self, actual, expected=None, is_deleted=False): + self._validate_object(actual) + + # Validate that the document has all expected fields and is a dict. + expected_fields = list(DOCUMENT_EXPECTED_FIELDS) + if not is_deleted: + expected_fields.remove('deleted_at') + + self.assertIsInstance(actual, dict) + for field in expected_fields: + self.assertIn(field, actual) + + if expected: + # Validate that the expected values are equivalent to actual + # values. + for key, val in expected.items(): + self.assertEqual(val, actual[key]) + + def _validate_revision(self, revision): + self._validate_object(revision) + + for attr in REVISION_EXPECTED_FIELDS: + self.assertIn(attr, revision) diff --git a/deckhand/tests/unit/db/test_documents.py b/deckhand/tests/unit/db/test_documents.py index 4fa1dd4f..b1baea1f 100644 --- a/deckhand/tests/unit/db/test_documents.py +++ b/deckhand/tests/unit/db/test_documents.py @@ -12,110 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import testtools -from testtools import matchers - -from deckhand.db.sqlalchemy import api as db_api -from deckhand.tests import test_utils -from deckhand.tests.unit import base - -BASE_EXPECTED_FIELDS = ("created_at", "updated_at", "deleted_at", "deleted") -DOCUMENT_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + ( - "id", "schema", "name", "metadata", "data", "revision_id") -REVISION_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + ( - "id", "child_id", "parent_id", "documents") +from deckhand.tests.unit.db import base -class DocumentFixture(object): - - @staticmethod - def get_minimal_fixture(**kwargs): - fixture = { - 'data': { - test_utils.rand_name('key'): test_utils.rand_name('value') - }, - '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 - - @staticmethod - def get_minimal_multi_fixture(count=2, **kwargs): - return [DocumentFixture.get_minimal_fixture(**kwargs) - for _ in range(count)] - - -class TestDocumentsBase(base.DeckhandWithDBTestCase): - - def _create_documents(self, payload): - if not isinstance(payload, list): - payload = [payload] - - docs = db_api.documents_create(payload) - for idx, doc in enumerate(docs): - self._validate_document(expected=payload[idx], actual=doc) - return docs - - def _get_document(self, **fields): - doc = db_api.document_get(**fields) - self._validate_document(actual=doc) - return doc - - def _get_revision(self, revision_id): - revision = db_api.revision_get(revision_id) - 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'): - self.assertThat(obj[attr], matchers.MatchesAny( - matchers.Is(None), matchers.IsInstance(str))) - else: - self.assertIsInstance(obj[attr], bool) - - def _validate_document(self, actual, expected=None, is_deleted=False): - self._validate_object(actual) - - # Validate that the document has all expected fields and is a dict. - expected_fields = list(DOCUMENT_EXPECTED_FIELDS) - if not is_deleted: - expected_fields.remove('deleted_at') - - self.assertIsInstance(actual, dict) - for field in expected_fields: - self.assertIn(field, actual) - - if expected: - # Validate that the expected values are equivalent to actual - # values. - for key, val in expected.items(): - self.assertEqual(val, actual[key]) - - def _validate_revision(self, revision): - self._validate_object(revision) - - for attr in REVISION_EXPECTED_FIELDS: - self.assertIn(attr, revision) - - -class TestDocuments(TestDocumentsBase): +class TestDocuments(base.TestDbBase): def test_create_and_get_document(self): - payload = DocumentFixture.get_minimal_fixture() + payload = base.DocumentFixture.get_minimal_fixture() documents = self._create_documents(payload) self.assertIsInstance(documents, list) @@ -126,7 +29,7 @@ class TestDocuments(TestDocumentsBase): self.assertEqual(document, retrieved_document) def test_create_document_again_with_no_changes(self): - payload = DocumentFixture.get_minimal_fixture() + payload = base.DocumentFixture.get_minimal_fixture() self._create_documents(payload) documents = self._create_documents(payload) @@ -134,7 +37,7 @@ class TestDocuments(TestDocumentsBase): self.assertEmpty(documents) def test_create_document_and_get_revision(self): - payload = DocumentFixture.get_minimal_fixture() + payload = base.DocumentFixture.get_minimal_fixture() documents = self._create_documents(payload) self.assertIsInstance(documents, list) @@ -146,7 +49,7 @@ class TestDocuments(TestDocumentsBase): self.assertEqual(document['revision_id'], revision['id']) def test_get_documents_by_revision_id(self): - payload = DocumentFixture.get_minimal_fixture() + payload = base.DocumentFixture.get_minimal_fixture() documents = self._create_documents(payload) revision = self._get_revision(documents[0]['revision_id']) @@ -154,7 +57,7 @@ class TestDocuments(TestDocumentsBase): self.assertEqual(documents[0], revision['documents'][0]) def test_get_multiple_documents_by_revision_id(self): - payload = DocumentFixture.get_minimal_multi_fixture(count=3) + payload = base.DocumentFixture.get_minimal_multi_fixture(count=3) documents = self._create_documents(payload) self.assertIsInstance(documents, list) @@ -166,7 +69,7 @@ class TestDocuments(TestDocumentsBase): self.assertEqual(document['revision_id'], revision['id']) def test_get_documents_by_revision_id_and_filters(self): - payload = DocumentFixture.get_minimal_fixture() + payload = base.DocumentFixture.get_minimal_fixture() document = self._create_documents(payload)[0] filters = { 'schema': document['schema'], diff --git a/deckhand/tests/unit/db/test_documents_negative.py b/deckhand/tests/unit/db/test_documents_negative.py index 372cb787..f7d2eaa3 100644 --- a/deckhand/tests/unit/db/test_documents_negative.py +++ b/deckhand/tests/unit/db/test_documents_negative.py @@ -12,18 +12,13 @@ # 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 +from deckhand.tests.unit.db import base -class TestDocumentsNegative(test_documents.TestDocumentsBase): +class TestDocumentsNegative(base.TestDbBase): def test_get_documents_by_revision_id_and_wrong_filters(self): - payload = test_documents.DocumentFixture.get_minimal_fixture() + payload = base.DocumentFixture.get_minimal_fixture() document = self._create_documents(payload)[0] filters = { 'schema': 'fake_schema', diff --git a/deckhand/tests/unit/db/test_revisions.py b/deckhand/tests/unit/db/test_revisions.py new file mode 100644 index 00000000..9bb8f6e0 --- /dev/null +++ b/deckhand/tests/unit/db/test_revisions.py @@ -0,0 +1,28 @@ +# 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. + +from deckhand.tests.unit.db import base + + +class TestRevisions(base.TestDbBase): + + def test_list_revisions(self): + payload = [base.DocumentFixture.get_minimal_fixture() + for _ in range(4)] + self._create_documents(payload) + + revisions = self._list_revisions() + self.assertIsInstance(revisions, list) + self.assertEqual(1, len(revisions)) + self.assertEqual(4, revisions[0]["count"])