[feat] DECKHAND-36 Revision tagging API

This commit adds an additional attribute called `tags` to each
Revision DB model. This allows Revisions to be tagged with whatever
arbitrary tag/tag data a service chooses to identify a revision by.

This commit:
  - creates a new DB model called `RevisionTag`
  - adds the following endpoints:
     * POST /api/v1.0/revisions/{revision_id}/tags/{tag} (create a tag)
     * GET /api/v1.0/revisions/tags/{tag} (show tag details)
     * GET /api/v1.0/revisions/{revision_id}/tags (list revision tags)
     * DELETE /api/v1.0/revisions/{revision_id}/tags/{tag} (delete a tag)
     * DELETE /api/v1.0/revisions/{revision_id}/tags (delete all tags)
  - adds appropriate unit test coverage for the changes
  - adds functional testing for each API endpoint

Change-Id: I49a7155ef5aa274c3a85ff6f8b85951f155a4b92
This commit is contained in:
Felipe Monteiro 2017-08-07 20:40:57 +01:00
parent c19309f347
commit 7b0a69b39a
20 changed files with 665 additions and 45 deletions

View File

@ -159,7 +159,7 @@ Document creation can be tested locally using (from root deckhand directory):
.. code-block:: console .. code-block:: console
$ curl -i -X POST localhost:9000/api/v1.0/documents \ $ curl -i -X PUT localhost:9000/api/v1.0/bucket/{bucket_name}/documents \
-H "Content-Type: application/x-yaml" \ -H "Content-Type: application/x-yaml" \
--data-binary "@deckhand/tests/unit/resources/sample_document.yaml" --data-binary "@deckhand/tests/unit/resources/sample_document.yaml"

View File

@ -23,6 +23,7 @@ from deckhand.control import base
from deckhand.control import buckets from deckhand.control import buckets
from deckhand.control import middleware from deckhand.control import middleware
from deckhand.control import revision_documents from deckhand.control import revision_documents
from deckhand.control import revision_tags
from deckhand.control import revisions from deckhand.control import revisions
from deckhand.control import secrets from deckhand.control import secrets
from deckhand.db.sqlalchemy import api as db_api from deckhand.db.sqlalchemy import api as db_api
@ -61,10 +62,13 @@ def __setup_db():
def _get_routing_map(): def _get_routing_map():
ROUTING_MAP = { ROUTING_MAP = {
'/api/v1.0/bucket/.+/documents': ['PUT'], '/api/v1.0/bucket/[A-za-z0-9\-]+/documents': ['PUT'],
'/api/v1.0/revisions': ['GET', 'DELETE'], '/api/v1.0/revisions': ['GET', 'DELETE'],
'/api/v1.0/revisions/.+': ['GET'], '/api/v1.0/revisions/[A-za-z0-9\-]+': ['GET'],
'/api/v1.0/revisions/documents': ['GET'] '/api/v1.0/revisions/[A-za-z0-9\-]+/tags': ['GET', 'DELETE'],
'/api/v1.0/revisions/[A-za-z0-9\-]+/tags/[A-za-z0-9\-]+': [
'GET', 'POST', 'DELETE'],
'/api/v1.0/revisions/[A-za-z0-9\-]+/documents': ['GET']
} }
for route in ROUTING_MAP.keys(): for route in ROUTING_MAP.keys():
@ -95,7 +99,10 @@ def start_api(state_manager=None):
('revisions', revisions.RevisionsResource()), ('revisions', revisions.RevisionsResource()),
('revisions/{revision_id}', revisions.RevisionsResource()), ('revisions/{revision_id}', revisions.RevisionsResource()),
('revisions/{revision_id}/documents', ('revisions/{revision_id}/documents',
revision_documents.RevisionDocumentsResource()), revision_documents.RevisionDocumentsResource()),
('revisions/{revision_id}/tags', revision_tags.RevisionTagsResource()),
('revisions/{revision_id}/tags/{tag}',
revision_tags.RevisionTagsResource()),
# TODO(fmontei): remove in follow-up commit. # TODO(fmontei): remove in follow-up commit.
('secrets', secrets.SecretsResource()) ('secrets', secrets.SecretsResource())
] ]

View File

@ -17,8 +17,6 @@ import yaml
import falcon import falcon
from oslo_context import context from oslo_context import context
from oslo_log import log as logging from oslo_log import log as logging
from oslo_serialization import jsonutils as json
import six
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -38,12 +36,6 @@ class BaseResource(object):
resp.headers['Allow'] = ','.join(allowed_methods) resp.headers['Allow'] = ','.join(allowed_methods)
resp.status = falcon.HTTP_200 resp.status = falcon.HTTP_200
def return_error(self, resp, status_code, message="", retry=False):
resp.body = json.dumps(
{'type': 'error', 'message': six.text_type(message),
'retry': retry})
resp.status = status_code
def to_yaml_body(self, dict_body): def to_yaml_body(self, dict_body):
"""Converts JSON body into YAML response body. """Converts JSON body into YAML response body.

View File

@ -42,7 +42,7 @@ class BucketsResource(api_base.BaseResource):
error_msg = ("Could not parse the document into YAML data. " error_msg = ("Could not parse the document into YAML data. "
"Details: %s." % e) "Details: %s." % e)
LOG.error(error_msg) LOG.error(error_msg)
return self.return_error(resp, falcon.HTTP_400, message=error_msg) raise falcon.HTTPBadRequest(description=e.format_message())
# All concrete documents in the payload must successfully pass their # All concrete documents in the payload must successfully pass their
# JSON schema validations. Otherwise raise an error. # JSON schema validations. Otherwise raise an error.
@ -50,15 +50,16 @@ class BucketsResource(api_base.BaseResource):
validation_policies = document_validation.DocumentValidation( validation_policies = document_validation.DocumentValidation(
documents).validate_all() documents).validate_all()
except (deckhand_errors.InvalidDocumentFormat) as e: except (deckhand_errors.InvalidDocumentFormat) as e:
return self.return_error(resp, falcon.HTTP_400, message=e) raise falcon.HTTPBadRequest(description=e.format_message())
try: try:
created_documents = db_api.documents_create( created_documents = db_api.documents_create(
bucket_name, documents, validation_policies) bucket_name, documents, validation_policies)
except db_exc.DBDuplicateEntry as e: except db_exc.DBDuplicateEntry as e:
raise falcon.HTTPConflict() raise falcon.HTTPConflict(description=e.format_message())
except Exception as e: except Exception as e:
raise falcon.HTTPInternalServerError() raise falcon.HTTPInternalServerError(
description=e.format_message())
if created_documents: if created_documents:
resp.body = self.to_yaml_body( resp.body = self.to_yaml_body(

View File

@ -40,8 +40,8 @@ class RevisionDocumentsResource(api_base.BaseResource):
try: try:
documents = db_api.revision_get_documents( documents = db_api.revision_get_documents(
revision_id, **sanitized_params) revision_id, **sanitized_params)
except errors.RevisionNotFound: except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound() raise falcon.HTTPNotFound(description=e.format_message())
resp.status = falcon.HTTP_200 resp.status = falcon.HTTP_200
resp.append_header('Content-Type', 'application/x-yaml') resp.append_header('Content-Type', 'application/x-yaml')

View File

@ -0,0 +1,113 @@
# 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 yaml
import falcon
from oslo_log import log as logging
from deckhand.control import base as api_base
from deckhand.control.views import revision_tag as revision_tag_view
from deckhand.db.sqlalchemy import api as db_api
from deckhand import errors
LOG = logging.getLogger(__name__)
class RevisionTagsResource(api_base.BaseResource):
"""API resource for realizing CRUD for revision tags."""
def on_post(self, req, resp, revision_id, tag=None):
"""Creates a revision tag."""
body = req.stream.read(req.content_length or 0)
try:
tag_data = yaml.safe_load(body)
except yaml.YAMLError as e:
error_msg = ("Could not parse the request body into YAML data. "
"Details: %s." % e)
LOG.error(error_msg)
raise falcon.HTTPBadRequest(description=e)
try:
resp_tag = db_api.revision_tag_create(revision_id, tag, tag_data)
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message())
except errors.RevisionTagBadFormat as e:
raise falcon.HTTPBadRequest(description=e.format_message())
resp_body = revision_tag_view.ViewBuilder().show(resp_tag)
resp.status = falcon.HTTP_201
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(resp_body)
def on_get(self, req, resp, revision_id, tag=None):
"""Show tag details or list all tags for a revision."""
if tag:
self._show_tag(req, resp, revision_id, tag)
else:
self._list_all_tags(req, resp, revision_id)
def _show_tag(self, req, resp, revision_id, tag):
"""Retrieve details for a specified tag."""
try:
resp_tag = db_api.revision_tag_get(revision_id, tag)
except (errors.RevisionNotFound,
errors.RevisionTagNotFound) as e:
raise falcon.HTTPNotFound(description=e.format_message())
resp_body = revision_tag_view.ViewBuilder().show(resp_tag)
resp.status = falcon.HTTP_200
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(resp_body)
def _list_all_tags(self, req, resp, revision_id):
"""List all tags for a revision."""
try:
resp_tags = db_api.revision_tag_get_all(revision_id)
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(e.format_message())
resp_body = revision_tag_view.ViewBuilder().list(resp_tags)
resp.status = falcon.HTTP_200
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(resp_body)
def on_delete(self, req, resp, revision_id, tag=None):
"""Deletes a single tag or deletes all tags for a revision."""
if tag:
self._delete_tag(req, resp, revision_id, tag)
else:
self._delete_all_tags(req, resp, revision_id)
def _delete_tag(self, req, resp, revision_id, tag):
"""Delete a specified tag."""
try:
db_api.revision_tag_delete(revision_id, tag)
except (errors.RevisionNotFound,
errors.RevisionTagNotFound) as e:
raise falcon.HTTPNotFound(description=e.format_message())
resp.append_header('Content-Type', 'application/x-yaml')
resp.status = falcon.HTTP_204
def _delete_all_tags(self, req, resp, revision_id):
"""Delete all tags for a revision."""
try:
db_api.revision_tag_delete_all(revision_id)
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message())
resp.append_header('Content-Type', 'application/x-yaml')
resp.status = falcon.HTTP_204

View File

@ -45,8 +45,8 @@ class RevisionsResource(api_base.BaseResource):
""" """
try: try:
revision = db_api.revision_get(revision_id) revision = db_api.revision_get(revision_id)
except errors.RevisionNotFound: except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound() raise falcon.HTTPNotFound(description=e.format_message())
revision_resp = self.view_builder.show(revision) revision_resp = self.view_builder.show(revision)
resp.status = falcon.HTTP_200 resp.status = falcon.HTTP_200

View File

@ -0,0 +1,33 @@
# 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.control import common
class ViewBuilder(common.ViewBuilder):
"""Model revision tag API responses as a python dictionary."""
_collection_name = 'revisions'
def list(self, tags):
return [self._show(tag) for tag in tags]
def show(self, tag):
return self._show(tag)
def _show(self, tag):
return {
'tag': tag.get('tag', None),
'data': tag.get('data', {})
}

View File

@ -17,6 +17,7 @@
import ast import ast
import copy import copy
import functools
import threading import threading
from oslo_config import cfg from oslo_config import cfg
@ -205,7 +206,6 @@ def bucket_get_or_create(bucket_name, session=None):
#################### ####################
def revision_create(session=None): def revision_create(session=None):
session = session or get_session() session = session or get_session()
@ -233,10 +233,23 @@ def revision_get(revision_id, session=None):
return revision.to_dict() return revision.to_dict()
def require_revision_exists(f):
"""Decorator to require the specified revision to exist.
Requires the wrapped function to use revision_id as the first argument.
"""
@functools.wraps(f)
def wrapper(revision_id, *args, **kwargs):
revision_get(revision_id)
return f(revision_id, *args, **kwargs)
return wrapper
def revision_get_all(session=None): def revision_get_all(session=None):
"""Return list of all revisions.""" """Return list of all revisions."""
session = session or get_session() session = session or get_session()
revisions = session.query(models.Revision).all() revisions = session.query(models.Revision)\
.all()
return [r.to_dict() for r in revisions] return [r.to_dict() for r in revisions]
@ -303,3 +316,93 @@ def _filter_revision_documents(documents, **filters):
filtered_documents.append(document) filtered_documents.append(document)
return filtered_documents return filtered_documents
####################
@require_revision_exists
def revision_tag_create(revision_id, tag, data=None, session=None):
"""Create a revision tag.
:returns: The tag that was created if not already present in the database,
else None.
"""
session = session or get_session()
tag_model = models.RevisionTag()
try:
assert not data or isinstance(data, dict)
except AssertionError:
raise errors.RevisionTagBadFormat(data=data)
try:
with session.begin():
tag_model.update(
{'tag': tag, 'data': data, 'revision_id': revision_id})
tag_model.save(session=session)
resp = tag_model.to_dict()
except db_exception.DBDuplicateEntry:
resp = None
return resp
@require_revision_exists
def revision_tag_get(revision_id, tag, session=None):
"""Retrieve tag details.
:returns: None
:raises RevisionTagNotFound: If ``tag`` for ``revision_id`` was not found.
"""
session = session or get_session()
try:
tag = session.query(models.RevisionTag)\
.filter_by(tag=tag, revision_id=revision_id)\
.one()
except sa_orm.exc.NoResultFound:
raise errors.RevisionTagNotFound(tag=tag, revision=revision_id)
return tag.to_dict()
@require_revision_exists
def revision_tag_get_all(revision_id, session=None):
"""Return list of tags for a revision.
:returns: List of tags for ``revision_id``, ordered by the tag name by
default.
"""
session = session or get_session()
tags = session.query(models.RevisionTag)\
.filter_by(revision_id=revision_id)\
.order_by(models.RevisionTag.tag)\
.all()
return [t.to_dict() for t in tags]
@require_revision_exists
def revision_tag_delete(revision_id, tag, session=None):
"""Delete a specific tag for a revision.
:returns: None
"""
session = session or get_session()
result = session.query(models.RevisionTag)\
.filter_by(tag=tag, revision_id=revision_id)\
.delete(synchronize_session=False)
if result == 0:
raise errors.RevisionTagNotFound(tag=tag, revision=revision_id)
@require_revision_exists
def revision_tag_delete_all(revision_id, session=None):
"""Delete all tags for a revision.
:returns: None
"""
session = session or get_session()
session.query(models.RevisionTag)\
.filter_by(revision_id=revision_id)\
.delete(synchronize_session=False)

View File

@ -26,6 +26,7 @@ from sqlalchemy import Integer
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy import schema from sqlalchemy import schema
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy import Unicode
# Declarative base class which maintains a catalog of classes and tables # Declarative base class which maintains a catalog of classes and tables
@ -41,10 +42,6 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
__protected_attributes__ = set([ __protected_attributes__ = set([
"created_at", "updated_at", "deleted_at", "deleted"]) "created_at", "updated_at", "deleted_at", "deleted"])
def save(self, session=None):
from deckhand.db.sqlalchemy import api as db_api
super(DeckhandBase, self).save(session or db_api.get_session())
created_at = Column(DateTime, default=lambda: timeutils.utcnow(), created_at = Column(DateTime, default=lambda: timeutils.utcnow(),
nullable=False) nullable=False)
updated_at = Column(DateTime, default=lambda: timeutils.utcnow(), updated_at = Column(DateTime, default=lambda: timeutils.utcnow(),
@ -52,11 +49,14 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
deleted_at = Column(DateTime, nullable=True) deleted_at = Column(DateTime, nullable=True)
deleted = Column(Boolean, nullable=False, default=False) deleted = Column(Boolean, nullable=False, default=False)
def save(self, session=None):
from deckhand.db.sqlalchemy import api as db_api
super(DeckhandBase, self).save(session or db_api.get_session())
def safe_delete(self, session=None): def safe_delete(self, session=None):
"""Delete this object."""
self.deleted = True self.deleted = True
self.deleted_at = timeutils.utcnow() self.deleted_at = timeutils.utcnow()
self.save(session=session) super(DeckhandBase, self).delete(session=session)
def keys(self): def keys(self):
return self.__dict__.keys() return self.__dict__.keys()
@ -68,22 +68,20 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
return self.__dict__.items() return self.__dict__.items()
def to_dict(self, raw_dict=False): def to_dict(self, raw_dict=False):
"""Conver the object into dictionary format. """Convert the object into dictionary format.
:param raw_dict: if True, returns unmodified data; else returns data :param raw_dict: Renames the key "_metadata" to "metadata".
expected by users.
""" """
d = self.__dict__.copy() d = self.__dict__.copy()
# Remove private state instance, as it is not serializable and causes # Remove private state instance, as it is not serializable and causes
# CircularReference. # CircularReference.
d.pop("_sa_instance_state") d.pop("_sa_instance_state")
if 'deleted_at' not in d: for k in ["created_at", "updated_at", "deleted_at", "deleted"]:
d.setdefault('deleted_at', None)
for k in ["created_at", "updated_at", "deleted_at"]:
if k in d and d[k]: if k in d and d[k]:
d[k] = d[k].isoformat() d[k] = d[k].isoformat()
else:
d.setdefault(k, None)
# NOTE(fmontei): ``metadata`` is reserved by the DB, so ``_metadata`` # NOTE(fmontei): ``metadata`` is reserved by the DB, so ``_metadata``
# must be used to store document metadata information in the DB. # must be used to store document metadata information in the DB.
@ -114,15 +112,29 @@ class Revision(BASE, DeckhandBase):
default=lambda: str(uuid.uuid4())) default=lambda: str(uuid.uuid4()))
documents = relationship("Document") documents = relationship("Document")
validation_policies = relationship("ValidationPolicy") validation_policies = relationship("ValidationPolicy")
tags = relationship("RevisionTag")
def to_dict(self): def to_dict(self):
d = super(Revision, self).to_dict() d = super(Revision, self).to_dict()
d['documents'] = [doc.to_dict() for doc in self.documents] d['documents'] = [doc.to_dict() for doc in self.documents]
d['validation_policies'] = [ d['validation_policies'] = [
vp.to_dict() for vp in self.validation_policies] vp.to_dict() for vp in self.validation_policies]
d['tags'] = [tag.to_dict() for tag in self.tags]
return d return d
class RevisionTag(BASE, DeckhandBase):
UNIQUE_CONSTRAINTS = ('tag', 'revision_id')
__tablename__ = 'revision_tags'
__table_args__ = (DeckhandBase.gen_unqiue_contraint(*UNIQUE_CONSTRAINTS),)
tag = Column(Unicode(80), primary_key=True, nullable=False)
data = Column(oslo_types.JsonEncodedDict(), nullable=True, default={})
revision_id = Column(
Integer, ForeignKey('revisions.id', ondelete='CASCADE'),
nullable=False)
class DocumentMixin(object): class DocumentMixin(object):
"""Mixin class for sharing common columns across all document resources """Mixin class for sharing common columns across all document resources
such as documents themselves, layering policies and validation policies. such as documents themselves, layering policies and validation policies.

View File

@ -111,5 +111,17 @@ class DocumentNotFound(DeckhandException):
class RevisionNotFound(DeckhandException): class RevisionNotFound(DeckhandException):
msg_fmt = ("The requested revision %(revision)s was not found.") msg_fmt = "The requested revision %(revision)s was not found."
code = 404 code = 404
class RevisionTagNotFound(DeckhandException):
msg_fmt = ("The requested tag %(tag)s for revision %(revision)s was not "
"found.")
code = 404
class RevisionTagBadFormat(DeckhandException):
msg_fmt = ("The requested tag data %(data)s must either be null or "
"dictionary.")
code = 400

View File

@ -0,0 +1,3 @@
---
last: good
random: data

View File

@ -0,0 +1,100 @@
# Test success paths for revision tag create, read, update and delete.
#
# 1. Purges existing data to ensure test isolation
# 2. Adds a document to a bucket to create a revision needed by these tests.
# 3. Creates a tag "foo" for the created revision.
# 4. Verifies:
# - Tag "foo" was created for the revision
# 5. Creates a tag "bar" with associated data for the same revision.
# 6. Verifies:
# - Tag "bar" was created with expected data.
# 7. Delete tag "foo" and verify that only tag "bar" is listed afterward.
# 8. Delete all tags and verify that no tags are listed.
defaults:
request_headers:
content-type: application/x-yaml
response_headers:
content-type: application/x-yaml
tests:
- name: purge
desc: Begin testing from known state.
DELETE: /api/v1.0/revisions
status: 204
# Create a revision implicitly by creating a document.
- name: initialize
desc: Create initial documents
PUT: /api/v1.0/bucket/mop/documents
status: 200
data: <@resources/design-doc-layering-sample.yaml
- name: create_tag
desc: Create a tag for the revision
POST: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/tags/foo
status: 201
response_multidoc_jsonpaths:
$[0].data: {}
$[0].tag: foo
- name: show_tag
desc: Verify showing created tag works
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags/foo
status: 200
response_multidoc_jsonpaths:
$[0].data: {}
$[0].tag: foo
- name: create_tag_with_data
desc: Create a tag with data for the revision
POST: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags/bar
status: 201
data: <@resources/sample-tag-data.yaml
response_multidoc_jsonpaths:
$[0].tag: bar
$[0].data.last: good
$[0].data.random: data
- name: list_tags
desc: Verify listing tags contains created tag
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 200
response_multidoc_jsonpaths:
$.[0].tag: bar
$.[0].data.last: good
$.[0].data.random: data
$.[1].tag: foo
$.[1].data: {}
- name: delete_tag
desc: Verify deleting tag works
DELETE: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags/foo
status: 204
- name: verify_tag_delete
desc: Verify listing tags contains non-deleted tag
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 200
response_multidoc_jsonpaths:
$.[0].tag: bar
$.[0].data.last: good
$.[0].data.random: data
- name: delete_all_tags
desc: Verify deleting tag works
DELETE: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 204
- name: verify_tag_delete_all
desc: Verify all tags have been deleted
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 200
response_multidoc_jsonpaths:
$: null

View File

@ -18,7 +18,6 @@ import gabbi.json_parser
import os import os
import yaml import yaml
TESTS_DIR = 'gabbits' TESTS_DIR = 'gabbits'
@ -40,12 +39,20 @@ class MultidocJsonpaths(gabbi.handlers.jsonhandler.JSONHandler):
@staticmethod @staticmethod
def loads(string): def loads(string):
# NOTE: The simple approach to handling dictionary versus list response
# bodies is to always parse the response body as a list and index into
# the first element using [0] throughout the tests.
return list(yaml.safe_load_all(string)) return list(yaml.safe_load_all(string))
def load_tests(loader, tests, pattern): def load_tests(loader, tests, pattern):
test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR) test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR)
return gabbi.driver.build_tests(test_dir, loader, return gabbi.driver.build_tests(test_dir, loader,
# NOTE(fmontei): When there are multiple handlers listed that
# accept the same content-type, the one that is earliest in the
# list will be used. Thus, we cannot specify multiple content
# handlers for handling list/dictionary responses from the server
# using different handlers.
content_handlers=[MultidocJsonpaths], content_handlers=[MultidocJsonpaths],
verbose=True, verbose=True,
url=os.environ['DECKHAND_TEST_URL']) url=os.environ['DECKHAND_TEST_URL'])

View File

@ -34,8 +34,11 @@ class DeckhandTestCase(testtools.TestCase):
CONF.set_override(name, override, group) CONF.set_override(name, override, group)
self.addCleanup(CONF.clear_override, name, group) self.addCleanup(CONF.clear_override, name, group)
def assertEmpty(self, list): def assertEmpty(self, collection):
self.assertEqual(0, len(list)) if isinstance(collection, list):
self.assertEqual(0, len(collection))
elif isinstance(collection, dict):
self.assertEqual(0, len(collection.keys()))
class DeckhandWithDBTestCase(DeckhandTestCase): class DeckhandWithDBTestCase(DeckhandTestCase):

View File

@ -18,6 +18,7 @@ from deckhand.control import api
from deckhand.control import base from deckhand.control import base
from deckhand.control import buckets from deckhand.control import buckets
from deckhand.control import revision_documents from deckhand.control import revision_documents
from deckhand.control import revision_tags
from deckhand.control import revisions from deckhand.control import revisions
from deckhand.control import secrets from deckhand.control import secrets
from deckhand.tests.unit import base as test_base from deckhand.tests.unit import base as test_base
@ -27,7 +28,8 @@ class TestApi(test_base.DeckhandTestCase):
def setUp(self): def setUp(self):
super(TestApi, self).setUp() super(TestApi, self).setUp()
for resource in (buckets, revision_documents, revisions, secrets): for resource in (buckets, revision_documents, revision_tags, revisions,
secrets):
resource_name = resource.__name__.split('.')[-1] resource_name = resource.__name__.split('.')[-1]
resource_obj = mock.patch.object( resource_obj = mock.patch.object(
resource, '%sResource' % resource_name.title().replace( resource, '%sResource' % resource_name.title().replace(
@ -54,6 +56,10 @@ class TestApi(test_base.DeckhandTestCase):
self.revisions_resource()), self.revisions_resource()),
mock.call('/api/v1.0/revisions/{revision_id}/documents', mock.call('/api/v1.0/revisions/{revision_id}/documents',
self.revision_documents_resource()), self.revision_documents_resource()),
mock.call('/api/v1.0/revisions/{revision_id}/tags',
self.revision_tags_resource()),
mock.call('/api/v1.0/revisions/{revision_id}/tags/{tag}',
self.revision_tags_resource()),
mock.call('/api/v1.0/secrets', self.secrets_resource()) mock.call('/api/v1.0/secrets', self.secrets_resource())
]) ])
mock_config.parse_args.assert_called_once_with() mock_config.parse_args.assert_called_once_with()

View File

@ -21,8 +21,7 @@ from deckhand.tests.unit import base
BASE_EXPECTED_FIELDS = ("created_at", "updated_at", "deleted_at", "deleted") BASE_EXPECTED_FIELDS = ("created_at", "updated_at", "deleted_at", "deleted")
DOCUMENT_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + ( DOCUMENT_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + (
"id", "schema", "name", "metadata", "data", "revision_id", "bucket_id") "id", "schema", "name", "metadata", "data", "revision_id", "bucket_id")
REVISION_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + ( REVISION_EXPECTED_FIELDS = ("id", "documents", "validation_policies", "tags")
"id", "documents", "validation_policies")
# TODO(fmontei): Move this into a separate module called `fixtures`. # TODO(fmontei): Move this into a separate module called `fixtures`.
@ -81,8 +80,13 @@ class TestDbBase(base.DeckhandWithDBTestCase):
return doc return doc
def delete_document(self, document_id): def create_revision(self):
return db_api.document_delete(document_id) # Implicitly creates a revision and returns it.
documents = [DocumentFixture.get_minimal_fixture()]
bucket_name = test_utils.rand_name('bucket')
revision_id = self.create_documents(bucket_name, documents)[0][
'revision_id']
return revision_id
def show_revision(self, revision_id): def show_revision(self, revision_id):
revision = db_api.revision_get(revision_id) revision = db_api.revision_get(revision_id)

View File

@ -0,0 +1,118 @@
# 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.db.sqlalchemy import api as db_api
from deckhand import errors
from deckhand.tests import test_utils
from deckhand.tests.unit.db import base
class TestRevisionTags(base.TestDbBase):
def setUp(self):
super(TestRevisionTags, self).setUp()
self.revision_id = self.create_revision()
def test_list_tags(self):
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEmpty(retrieved_tags)
def test_create_show_and_list_many_tags_without_data(self):
expected_tag_names = []
for _ in range(4):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
db_api.revision_tag_create(self.revision_id, tag)
expected_tag_names.append(tag)
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
retrieved_tag_names = [t['tag'] for t in retrieved_tags]
self.assertEqual(4, len(retrieved_tags))
self.assertEqual(sorted(expected_tag_names), retrieved_tag_names)
for tag in expected_tag_names:
# Should not raise an exception.
resp = db_api.revision_tag_get(self.revision_id, tag)
self.assertIn('tag', resp)
self.assertIn('data', resp)
def test_create_show_and_list_many_tags_with_data(self):
expected_tags = []
for _ in range(4):
rand_prefix = test_utils.rand_name(self.__class__.__name__)
tag = rand_prefix + '-Tag'
data_key = rand_prefix + '-Key'
data_val = rand_prefix + '-Val'
db_api.revision_tag_create(
self.revision_id, tag, {data_key: data_val})
expected_tags.append({'tag': tag, 'data': {data_key: data_val}})
expected_tags = sorted(expected_tags, key=lambda t: t['tag'])
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEqual(4, len(retrieved_tags))
retrieved_tags = [
{k: t[k] for k in t.keys() if k in ('data', 'tag')}
for t in retrieved_tags]
self.assertEqual(sorted(expected_tags, key=lambda t: t['tag']),
retrieved_tags)
def test_create_and_delete_tags(self):
tags = []
for _ in range(4):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
db_api.revision_tag_create(self.revision_id, tag)
tags.append(tag)
for idx, tag in enumerate(tags):
expected_tag_names = sorted(tags[idx + 1:])
result = db_api.revision_tag_delete(self.revision_id, tag)
self.assertIsNone(result)
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
retrieved_tag_names = [t['tag'] for t in retrieved_tags]
self.assertEqual(expected_tag_names, retrieved_tag_names)
self.assertRaises(
errors.RevisionTagNotFound, db_api.revision_tag_get,
self.revision_id, tag)
def test_delete_all_tags(self):
for _ in range(4):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
db_api.revision_tag_create(self.revision_id, tag)
result = db_api.revision_tag_delete_all(self.revision_id)
self.assertIsNone(result)
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEmpty(retrieved_tags)
def test_delete_all_tags_without_any_tags(self):
# Validate that no tags exist to begin with.
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEmpty(retrieved_tags)
# Validate that deleting all tags without any tags doesn't raise
# errors.
db_api.revision_tag_delete_all(self.revision_id)
def test_create_duplicate_tag(self):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
# Create the same tag twice and validate that it returns None the
# second time.
db_api.revision_tag_create(self.revision_id, tag)
resp = db_api.revision_tag_create(self.revision_id, tag)
self.assertIsNone(resp)

View File

@ -0,0 +1,46 @@
# 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.db.sqlalchemy import api as db_api
from deckhand import errors
from deckhand.tests import test_utils
from deckhand.tests.unit.db import base
class TestRevisionTagsNegative(base.TestDbBase):
def test_create_tag_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_create,
test_utils.rand_uuid_hex())
def test_show_tag_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_get,
test_utils.rand_uuid_hex())
def test_delete_tag_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_delete,
test_utils.rand_uuid_hex())
def test_list_tags_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_get_all,
test_utils.rand_uuid_hex())
def test_delete_all_tags_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_delete_all,
test_utils.rand_uuid_hex())

View File

@ -0,0 +1,60 @@
# 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.control.views import revision_tag
from deckhand.db.sqlalchemy import api as db_api
from deckhand.tests import test_utils
from deckhand.tests.unit.db import base
class TestRevisionViews(base.TestDbBase):
def setUp(self):
super(TestRevisionViews, self).setUp()
self.view_builder = revision_tag.ViewBuilder()
self.revision_id = self.create_revision()
def test_revision_tag_show_view(self):
rand_prefix = test_utils.rand_name(self.__class__.__name__)
tag = rand_prefix + '-Tag'
data_key = rand_prefix + '-Key'
data_val = rand_prefix + '-Val'
expected_view = {'tag': tag, 'data': {data_key: data_val}}
created_tag = db_api.revision_tag_create(
self.revision_id, tag, {data_key: data_val})
actual_view = self.view_builder.show(created_tag)
self.assertEqual(expected_view, actual_view)
def test_revision_tag_list_view(self):
expected_view = []
# Create 2 revision tags for the same revision.
for _ in range(2):
rand_prefix = test_utils.rand_name(self.__class__.__name__)
tag = rand_prefix + '-Tag'
data_key = rand_prefix + '-Key'
data_val = rand_prefix + '-Val'
db_api.revision_tag_create(
self.revision_id, tag, {data_key: data_val})
expected_view.append({'tag': tag, 'data': {data_key: data_val}})
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
actual_view = self.view_builder.list(retrieved_tags)
self.assertEqual(sorted(expected_view, key=lambda t: t['tag']),
actual_view)