Add zones_export_client's methods and tests to Designate tempest plugin

Partially-Implements: blueprint designate-tempest-plugin

Change-Id: Iff03a53842ac4e44ed720163695a35a74b970768
diff --git a/designate_tempest_plugin/clients.py b/designate_tempest_plugin/clients.py
index d042f65..d461a53 100644
--- a/designate_tempest_plugin/clients.py
+++ b/designate_tempest_plugin/clients.py
@@ -22,6 +22,8 @@
     BlacklistsClient
 from designate_tempest_plugin.services.dns.admin.json.quotas_client import \
     QuotasClient
+from designate_tempest_plugin.services.dns.v2.json.zone_exports_client import \
+    ZoneExportsClient
 
 CONF = config.CONF
 
@@ -44,3 +46,5 @@
                                                      **params)
         self.blacklists_client = BlacklistsClient(self.auth_provider, **params)
         self.quotas_client = QuotasClient(self.auth_provider, **params)
+        self.zone_exports_client = ZoneExportsClient(self.auth_provider,
+                                                     **params)
diff --git a/designate_tempest_plugin/common/models.py b/designate_tempest_plugin/common/models.py
new file mode 100644
index 0000000..e69bd09
--- /dev/null
+++ b/designate_tempest_plugin/common/models.py
@@ -0,0 +1,81 @@
+"""
+Copyright 2015 Rackspace
+
+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.
+"""
+
+
+class ZoneFile(object):
+
+    def __init__(self, origin, ttl, records):
+        self.origin = origin
+        self.ttl = ttl
+        self.records = records
+
+    def __str__(self):
+        return str(self.__dict__)
+
+    def __repr__(self):
+        return str(self)
+
+    def __eq__(self, other):
+        return self.__dict__ == other.__dict__
+
+    @classmethod
+    def from_text(cls, text):
+        """Return a ZoneFile from a string containing the zone file contents"""
+        # filter out empty lines and strip all leading/trailing whitespace.
+        # this assumes no multiline records
+        lines = [x.strip() for x in text.split('\n') if x.strip()]
+
+        assert lines[0].startswith('$ORIGIN')
+        assert lines[1].startswith('$TTL')
+
+        return ZoneFile(
+            origin=lines[0].split(' ')[1],
+            ttl=int(lines[1].split(' ')[1]),
+            records=[ZoneFileRecord.from_text(x) for x in lines[2:]],
+        )
+
+
+class ZoneFileRecord(object):
+
+    def __init__(self, name, type, data):
+        self.name = str(name)
+        self.type = str(type)
+        self.data = str(data)
+
+    def __str__(self):
+        return str(self.__dict__)
+
+    def __repr__(self):
+        return str(self)
+
+    def __eq__(self, other):
+        return self.__dict__ == other.__dict__
+
+    def __hash__(self):
+        return hash(tuple(sorted(self.__dict__.items())))
+
+    @classmethod
+    def from_text(cls, text):
+        """Create a ZoneFileRecord from a line of text of a zone file, like:
+
+            mydomain.com. IN NS ns1.example.com.
+        """
+        # assumes records don't have a TTL between the name and the class.
+        # assumes no parentheses in the record, all on a single line.
+        parts = [x for x in text.split(' ', 4) if x.strip()]
+        name, rclass, rtype, data = parts
+        assert rclass == 'IN'
+        return cls(name=name, type=rtype, data=data)
diff --git a/designate_tempest_plugin/services/dns/json/base.py b/designate_tempest_plugin/services/dns/json/base.py
index 9068c78..b53712f 100644
--- a/designate_tempest_plugin/services/dns/json/base.py
+++ b/designate_tempest_plugin/services/dns/json/base.py
@@ -16,8 +16,10 @@
 from oslo_log import log as logging
 from oslo_serialization import jsonutils as json
 from tempest.lib.common import rest_client
+from tempest.lib import exceptions as lib_exc
 from six.moves.urllib import parse as urllib
 
+from designate_tempest_plugin.common.models import ZoneFile
 
 LOG = logging.getLogger(__name__)
 
@@ -50,8 +52,13 @@
     def serialize(self, object_dict):
         return json.dumps(object_dict)
 
-    def deserialize(self, object_str):
-        return json.loads(object_str)
+    def deserialize(self, resp, object_str):
+        if 'application/json' in resp['content-type']:
+            return json.loads(object_str)
+        elif 'text/dns' in resp['content-type']:
+            return ZoneFile.from_text(object_str)
+        else:
+            raise lib_exc.InvalidContentType()
 
     def expected_success(self, expected_code, read_code):
         # the base class method does not check correctly if read_code is not
@@ -84,7 +91,7 @@
                                   uuid=uuid,
                                   params=params)
 
-    def _create_request(self, resource, object_dict, params=None,
+    def _create_request(self, resource, object_dict=None, params=None,
                         headers=None, extra_headers=False):
         """Create an object of the specified type.
         :param resource: The name of the REST resource, e.g., 'zones'.
@@ -108,9 +115,9 @@
                                extra_headers=extra_headers)
         self.expected_success([201, 202], resp.status)
 
-        return resp, self.deserialize(body)
+        return resp, self.deserialize(resp, body)
 
-    def _show_request(self, resource, uuid, params=None):
+    def _show_request(self, resource, uuid, headers=None, params=None):
         """Gets a specific object of the specified type.
         :param resource: The name of the REST resource, e.g., 'zones'.
         :param uuid: Unique identifier of the object in UUID format.
@@ -120,11 +127,11 @@
         """
         uri = self.get_uri(resource, uuid=uuid, params=params)
 
-        resp, body = self.get(uri)
+        resp, body = self.get(uri, headers=headers)
 
         self.expected_success(200, resp.status)
 
-        return resp, self.deserialize(body)
+        return resp, self.deserialize(resp, body)
 
     def _list_request(self, resource, params=None):
         """Gets a list of objects.
@@ -139,7 +146,7 @@
 
         self.expected_success(200, resp.status)
 
-        return resp, self.deserialize(body)
+        return resp, self.deserialize(resp, body)
 
     def _update_request(self, resource, uuid, object_dict, params=None):
         """Updates the specified object.
@@ -158,7 +165,7 @@
 
         self.expected_success([200, 202], resp.status)
 
-        return resp, self.deserialize(body)
+        return resp, self.deserialize(resp, body)
 
     def _delete_request(self, resource, uuid, params=None):
         """Deletes the specified object.
@@ -173,6 +180,6 @@
         resp, body = self.delete(uri)
         self.expected_success([202, 204], resp.status)
         if resp.status == 202:
-            body = self.deserialize(body)
+            body = self.deserialize(resp, body)
 
         return resp, body
diff --git a/designate_tempest_plugin/services/dns/v2/json/zone_exports_client.py b/designate_tempest_plugin/services/dns/v2/json/zone_exports_client.py
new file mode 100644
index 0000000..15c8339
--- /dev/null
+++ b/designate_tempest_plugin/services/dns/v2/json/zone_exports_client.py
@@ -0,0 +1,93 @@
+# Copyright 2016 NEC Corporation.  All rights reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from designate_tempest_plugin.common import waiters
+from designate_tempest_plugin.services.dns.v2.json import base
+
+
+class ZoneExportsClient(base.DnsClientV2Base):
+
+    @base.handle_errors
+    def create_zone_export(self, uuid, params=None, wait_until=False):
+        """Create a zone export.
+        :param uuid: Unique identifier of the zone in UUID format.
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :param wait_until: Block until the exported zone reaches the
+                           desired status
+        :return: Serialized imported zone as a dictionary.
+        """
+
+        export_uri = 'zones/{0}/tasks/export'.format(uuid)
+        resp, body = self._create_request(
+            export_uri, params=params)
+
+        # Create Zone Export should Return a HTTP 202
+        self.expected_success(202, resp.status)
+
+        if wait_until:
+            waiters.wait_for_zone_export_status(self, body['id'], wait_until)
+
+        return resp, body
+
+    @base.handle_errors
+    def show_zone_export_records(self, uuid, params=None):
+        """Gets a specific exported zone.
+        :param uuid: Unique identifier of the exported zone in UUID format.
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :return: Serialized exported zone as a dictionary.
+        """
+        return self._show_request(
+             'zones/tasks/exports', uuid, params=params)
+
+    @base.handle_errors
+    def show_zone_exported(self, uuid, params=None):
+        """Gets a specific exported zone.
+        :param uuid: Unique identifier of the exported zone in UUID format.
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :return: Serialized exported zone as a dictionary.
+        """
+        headers = {'Accept': 'text/dns'}
+
+        return self._show_request(
+            'zones/tasks/exports/{0}/export'.format(uuid),
+            headers=headers, params=params)
+
+    @base.handle_errors
+    def list_zones_exports(self, params=None):
+        """Gets all the exported zones
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :return: Serialized exported zone as a list.
+        """
+        return self._list_request(
+            'zones/tasks/exports', params=params)
+
+    @base.handle_errors
+    def delete_zone_export(self, uuid, params=None):
+        """Deletes an exported zone having the specified UUID.
+        :param uuid: The unique identifier of the exported zone.
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :return: A tuple with the server response and the response body.
+        """
+        resp, body = self._delete_request(
+            'zones/tasks/exports', uuid, params=params)
+
+        # Delete Zone export should Return a HTTP 204
+        self.expected_success(204, resp.status)
+
+        return resp, body
diff --git a/designate_tempest_plugin/tests/api/v2/test_zones_exports.py b/designate_tempest_plugin/tests/api/v2/test_zones_exports.py
new file mode 100644
index 0000000..d11bb64
--- /dev/null
+++ b/designate_tempest_plugin/tests/api/v2/test_zones_exports.py
@@ -0,0 +1,97 @@
+# Copyright 2016 NEC Corporation.  All rights reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from oslo_log import log as logging
+from tempest import test
+from tempest.lib import exceptions as lib_exc
+
+from designate_tempest_plugin.tests import base
+
+LOG = logging.getLogger(__name__)
+
+
+class BaseZoneExportsTest(base.BaseDnsTest):
+    excluded_keys = ['created_at', 'updated_at', 'version', 'links',
+                     'status', 'location']
+
+
+class ZonesExportTest(BaseZoneExportsTest):
+    @classmethod
+    def setup_clients(cls):
+        super(ZonesExportTest, cls).setup_clients()
+
+        cls.zone_client = cls.os.zones_client
+        cls.client = cls.os.zone_exports_client
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('2dd8a9a0-98a2-4bf6-bb51-286583b30f40')
+    def test_create_zone_export(self):
+        LOG.info('Create a zone')
+        _, zone = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone['id'])
+
+        LOG.info('Create a zone export')
+        _, zone_export = self.client.create_zone_export(zone['id'])
+
+        LOG.info('Ensure we respond with PENDING')
+        self.assertEqual('PENDING', zone_export['status'])
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('2d29a2a9-1941-4b7e-9d8a-ad6c2140ea68')
+    def test_show_zone_export(self):
+        LOG.info('Create a zone')
+        _, zone = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone['id'])
+
+        LOG.info('Create a zone export')
+        resp, zone_export = self.client.create_zone_export(zone['id'])
+
+        LOG.info('Re-Fetch the zone export records')
+        _, body = self.client.show_zone_export_records(str(zone_export['id']))
+
+        LOG.info('Ensure the fetched response matches the records of '
+                 'exported zone')
+        self.assertExpected(zone_export, body, self.excluded_keys)
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('97234f00-8bcb-43f8-84dd-874f8bc4a80e')
+    def test_delete_zone_export(self):
+        LOG.info('Create a zone')
+        _, zone = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone['id'],
+                        ignore_errors=lib_exc.NotFound)
+
+        LOG.info('Create a zone export')
+        _, zone_export = self.client.create_zone_export(zone['id'])
+
+        LOG.info('Delete the exported zone')
+        _, body = self.client.delete_zone_export(zone_export['id'])
+
+        LOG.info('Ensure the exported zone has been successfully deleted')
+        self.assertRaises(lib_exc.NotFound,
+            lambda: self.client.show_zone_export_records(zone_export['id']))
+
+    @test.attr(type='smoke')
+    @test.idempotent_id('476bfdfe-58c8-46e2-b376-8403c0fff440')
+    def test_list_zones_exports(self):
+        LOG.info('Create a zone')
+        _, zone = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone['id'])
+
+        _, export = self.client.create_zone_export(zone['id'])
+
+        LOG.info('List zones exports')
+        _, body = self.client.list_zones_exports()
+
+        self.assertTrue(len(body['exports']) > 0)