[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:
parent
c19309f347
commit
7b0a69b39a
|
@ -159,7 +159,7 @@ Document creation can be tested locally using (from root deckhand directory):
|
|||
|
||||
.. 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" \
|
||||
--data-binary "@deckhand/tests/unit/resources/sample_document.yaml"
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ from deckhand.control import base
|
|||
from deckhand.control import buckets
|
||||
from deckhand.control import middleware
|
||||
from deckhand.control import revision_documents
|
||||
from deckhand.control import revision_tags
|
||||
from deckhand.control import revisions
|
||||
from deckhand.control import secrets
|
||||
from deckhand.db.sqlalchemy import api as db_api
|
||||
|
@ -61,10 +62,13 @@ def __setup_db():
|
|||
|
||||
def _get_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'],
|
||||
'/api/v1.0/revisions/documents': ['GET']
|
||||
'/api/v1.0/revisions/[A-za-z0-9\-]+': ['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():
|
||||
|
@ -95,7 +99,10 @@ def start_api(state_manager=None):
|
|||
('revisions', revisions.RevisionsResource()),
|
||||
('revisions/{revision_id}', revisions.RevisionsResource()),
|
||||
('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.
|
||||
('secrets', secrets.SecretsResource())
|
||||
]
|
||||
|
|
|
@ -17,8 +17,6 @@ import yaml
|
|||
import falcon
|
||||
from oslo_context import context
|
||||
from oslo_log import log as logging
|
||||
from oslo_serialization import jsonutils as json
|
||||
import six
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
@ -38,12 +36,6 @@ class BaseResource(object):
|
|||
resp.headers['Allow'] = ','.join(allowed_methods)
|
||||
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):
|
||||
"""Converts JSON body into YAML response body.
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ class BucketsResource(api_base.BaseResource):
|
|||
error_msg = ("Could not parse the document into YAML data. "
|
||||
"Details: %s." % e)
|
||||
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
|
||||
# JSON schema validations. Otherwise raise an error.
|
||||
|
@ -50,15 +50,16 @@ class BucketsResource(api_base.BaseResource):
|
|||
validation_policies = document_validation.DocumentValidation(
|
||||
documents).validate_all()
|
||||
except (deckhand_errors.InvalidDocumentFormat) as e:
|
||||
return self.return_error(resp, falcon.HTTP_400, message=e)
|
||||
raise falcon.HTTPBadRequest(description=e.format_message())
|
||||
|
||||
try:
|
||||
created_documents = db_api.documents_create(
|
||||
bucket_name, documents, validation_policies)
|
||||
except db_exc.DBDuplicateEntry as e:
|
||||
raise falcon.HTTPConflict()
|
||||
raise falcon.HTTPConflict(description=e.format_message())
|
||||
except Exception as e:
|
||||
raise falcon.HTTPInternalServerError()
|
||||
raise falcon.HTTPInternalServerError(
|
||||
description=e.format_message())
|
||||
|
||||
if created_documents:
|
||||
resp.body = self.to_yaml_body(
|
||||
|
|
|
@ -40,8 +40,8 @@ class RevisionDocumentsResource(api_base.BaseResource):
|
|||
try:
|
||||
documents = db_api.revision_get_documents(
|
||||
revision_id, **sanitized_params)
|
||||
except errors.RevisionNotFound:
|
||||
raise falcon.HTTPNotFound()
|
||||
except errors.RevisionNotFound as e:
|
||||
raise falcon.HTTPNotFound(description=e.format_message())
|
||||
|
||||
resp.status = falcon.HTTP_200
|
||||
resp.append_header('Content-Type', 'application/x-yaml')
|
||||
|
|
|
@ -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
|
|
@ -45,8 +45,8 @@ class RevisionsResource(api_base.BaseResource):
|
|||
"""
|
||||
try:
|
||||
revision = db_api.revision_get(revision_id)
|
||||
except errors.RevisionNotFound:
|
||||
raise falcon.HTTPNotFound()
|
||||
except errors.RevisionNotFound as e:
|
||||
raise falcon.HTTPNotFound(description=e.format_message())
|
||||
|
||||
revision_resp = self.view_builder.show(revision)
|
||||
resp.status = falcon.HTTP_200
|
||||
|
|
|
@ -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', {})
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
import ast
|
||||
import copy
|
||||
import functools
|
||||
import threading
|
||||
|
||||
from oslo_config import cfg
|
||||
|
@ -205,7 +206,6 @@ def bucket_get_or_create(bucket_name, session=None):
|
|||
|
||||
####################
|
||||
|
||||
|
||||
def revision_create(session=None):
|
||||
session = session or get_session()
|
||||
|
||||
|
@ -233,10 +233,23 @@ def revision_get(revision_id, session=None):
|
|||
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):
|
||||
"""Return list of all revisions."""
|
||||
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]
|
||||
|
||||
|
||||
|
@ -303,3 +316,93 @@ def _filter_revision_documents(documents, **filters):
|
|||
filtered_documents.append(document)
|
||||
|
||||
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)
|
||||
|
|
|
@ -26,6 +26,7 @@ from sqlalchemy import Integer
|
|||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import schema
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Unicode
|
||||
|
||||
|
||||
# Declarative base class which maintains a catalog of classes and tables
|
||||
|
@ -41,10 +42,6 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
|
|||
__protected_attributes__ = set([
|
||||
"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(),
|
||||
nullable=False)
|
||||
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 = 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):
|
||||
"""Delete this object."""
|
||||
self.deleted = True
|
||||
self.deleted_at = timeutils.utcnow()
|
||||
self.save(session=session)
|
||||
super(DeckhandBase, self).delete(session=session)
|
||||
|
||||
def keys(self):
|
||||
return self.__dict__.keys()
|
||||
|
@ -68,22 +68,20 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
|
|||
return self.__dict__.items()
|
||||
|
||||
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
|
||||
expected by users.
|
||||
:param raw_dict: Renames the key "_metadata" to "metadata".
|
||||
"""
|
||||
d = self.__dict__.copy()
|
||||
# Remove private state instance, as it is not serializable and causes
|
||||
# CircularReference.
|
||||
d.pop("_sa_instance_state")
|
||||
|
||||
if 'deleted_at' not in d:
|
||||
d.setdefault('deleted_at', None)
|
||||
|
||||
for k in ["created_at", "updated_at", "deleted_at"]:
|
||||
for k in ["created_at", "updated_at", "deleted_at", "deleted"]:
|
||||
if k in d and d[k]:
|
||||
d[k] = d[k].isoformat()
|
||||
else:
|
||||
d.setdefault(k, None)
|
||||
|
||||
# NOTE(fmontei): ``metadata`` is reserved by the DB, so ``_metadata``
|
||||
# must be used to store document metadata information in the DB.
|
||||
|
@ -114,15 +112,29 @@ class Revision(BASE, DeckhandBase):
|
|||
default=lambda: str(uuid.uuid4()))
|
||||
documents = relationship("Document")
|
||||
validation_policies = relationship("ValidationPolicy")
|
||||
tags = relationship("RevisionTag")
|
||||
|
||||
def to_dict(self):
|
||||
d = super(Revision, self).to_dict()
|
||||
d['documents'] = [doc.to_dict() for doc in self.documents]
|
||||
d['validation_policies'] = [
|
||||
vp.to_dict() for vp in self.validation_policies]
|
||||
d['tags'] = [tag.to_dict() for tag in self.tags]
|
||||
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):
|
||||
"""Mixin class for sharing common columns across all document resources
|
||||
such as documents themselves, layering policies and validation policies.
|
||||
|
|
|
@ -111,5 +111,17 @@ class DocumentNotFound(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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
last: good
|
||||
random: data
|
|
@ -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
|
|
@ -18,7 +18,6 @@ import gabbi.json_parser
|
|||
import os
|
||||
import yaml
|
||||
|
||||
|
||||
TESTS_DIR = 'gabbits'
|
||||
|
||||
|
||||
|
@ -40,12 +39,20 @@ class MultidocJsonpaths(gabbi.handlers.jsonhandler.JSONHandler):
|
|||
|
||||
@staticmethod
|
||||
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))
|
||||
|
||||
|
||||
def load_tests(loader, tests, pattern):
|
||||
test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR)
|
||||
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],
|
||||
verbose=True,
|
||||
url=os.environ['DECKHAND_TEST_URL'])
|
||||
|
|
|
@ -34,8 +34,11 @@ class DeckhandTestCase(testtools.TestCase):
|
|||
CONF.set_override(name, override, group)
|
||||
self.addCleanup(CONF.clear_override, name, group)
|
||||
|
||||
def assertEmpty(self, list):
|
||||
self.assertEqual(0, len(list))
|
||||
def assertEmpty(self, collection):
|
||||
if isinstance(collection, list):
|
||||
self.assertEqual(0, len(collection))
|
||||
elif isinstance(collection, dict):
|
||||
self.assertEqual(0, len(collection.keys()))
|
||||
|
||||
|
||||
class DeckhandWithDBTestCase(DeckhandTestCase):
|
||||
|
|
|
@ -18,6 +18,7 @@ from deckhand.control import api
|
|||
from deckhand.control import base
|
||||
from deckhand.control import buckets
|
||||
from deckhand.control import revision_documents
|
||||
from deckhand.control import revision_tags
|
||||
from deckhand.control import revisions
|
||||
from deckhand.control import secrets
|
||||
from deckhand.tests.unit import base as test_base
|
||||
|
@ -27,7 +28,8 @@ class TestApi(test_base.DeckhandTestCase):
|
|||
|
||||
def setUp(self):
|
||||
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_obj = mock.patch.object(
|
||||
resource, '%sResource' % resource_name.title().replace(
|
||||
|
@ -54,6 +56,10 @@ class TestApi(test_base.DeckhandTestCase):
|
|||
self.revisions_resource()),
|
||||
mock.call('/api/v1.0/revisions/{revision_id}/documents',
|
||||
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_config.parse_args.assert_called_once_with()
|
||||
|
|
|
@ -21,8 +21,7 @@ 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", "bucket_id")
|
||||
REVISION_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + (
|
||||
"id", "documents", "validation_policies")
|
||||
REVISION_EXPECTED_FIELDS = ("id", "documents", "validation_policies", "tags")
|
||||
|
||||
|
||||
# TODO(fmontei): Move this into a separate module called `fixtures`.
|
||||
|
@ -81,8 +80,13 @@ class TestDbBase(base.DeckhandWithDBTestCase):
|
|||
|
||||
return doc
|
||||
|
||||
def delete_document(self, document_id):
|
||||
return db_api.document_delete(document_id)
|
||||
def create_revision(self):
|
||||
# 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):
|
||||
revision = db_api.revision_get(revision_id)
|
||||
|
|
|
@ -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)
|
|
@ -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())
|
|
@ -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)
|
Loading…
Reference in New Issue