This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new b05e7db  feat: API for asset sync (#19220)
b05e7db is described below

commit b05e7dbf2aa513cd50a20847a858625916579dcb
Author: Beto Dealmeida <[email protected]>
AuthorDate: Tue Mar 22 08:29:24 2022 -0700

    feat: API for asset sync (#19220)
    
    * feat: API for asset sync
    
    * Add unit tests.
    
    * Improve tests
    
    * Move files
    
    * Add more tests
---
 superset/commands/importers/exceptions.py          |   5 +
 superset/importexport/api.py                       | 163 +++++++++++++
 superset/initialization/__init__.py                |   4 +-
 tests/unit_tests/conftest.py                       |  46 ++--
 .../unit_tests/importexport/__init__.py            |  12 -
 tests/unit_tests/importexport/api_test.py          | 254 +++++++++++++++++++++
 .../unit_tests/views/__init__.py                   |  12 -
 7 files changed, 458 insertions(+), 38 deletions(-)

diff --git a/superset/commands/importers/exceptions.py 
b/superset/commands/importers/exceptions.py
index c1beb8e..54388bd 100644
--- a/superset/commands/importers/exceptions.py
+++ b/superset/commands/importers/exceptions.py
@@ -26,3 +26,8 @@ class IncorrectVersionError(CommandException):
 class NoValidFilesFoundError(CommandException):
     status = 400
     message = "No valid import files were found"
+
+
+class IncorrectFormatError(CommandException):
+    status = 422
+    message = "File has the incorrect format"
diff --git a/superset/importexport/api.py b/superset/importexport/api.py
new file mode 100644
index 0000000..156b4c2
--- /dev/null
+++ b/superset/importexport/api.py
@@ -0,0 +1,163 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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 json
+from datetime import datetime
+from io import BytesIO
+from zipfile import is_zipfile, ZipFile
+
+from flask import request, Response, send_file
+from flask_appbuilder.api import BaseApi, expose, protect
+
+from superset.commands.export.assets import ExportAssetsCommand
+from superset.commands.importers.exceptions import (
+    IncorrectFormatError,
+    NoValidFilesFoundError,
+)
+from superset.commands.importers.v1.assets import ImportAssetsCommand
+from superset.commands.importers.v1.utils import get_contents_from_bundle
+from superset.extensions import event_logger
+from superset.views.base_api import requires_form_data
+
+
+class ImportExportRestApi(BaseApi):
+    """
+    API for exporting all assets or importing them.
+    """
+
+    resource_name = "assets"
+    openapi_spec_tag = "Import/export"
+
+    @expose("/export/", methods=["GET"])
+    @protect()
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.export",
+        log_to_statsd=False,
+    )
+    def export(self) -> Response:
+        """
+        Export all assets.
+        ---
+        get:
+          description: >-
+            Returns a ZIP file with all the Superset assets (databases, 
datasets, charts,
+            dashboards, saved queries) as YAML files.
+          responses:
+            200:
+              description: ZIP file
+              content:
+                application/zip:
+                  schema:
+                    type: string
+                    format: binary
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
+        root = f"assets_export_{timestamp}"
+        filename = f"{root}.zip"
+
+        buf = BytesIO()
+        with ZipFile(buf, "w") as bundle:
+            for file_name, file_content in ExportAssetsCommand().run():
+                with bundle.open(f"{root}/{file_name}", "w") as fp:
+                    fp.write(file_content.encode())
+        buf.seek(0)
+
+        response = send_file(
+            buf,
+            mimetype="application/zip",
+            as_attachment=True,
+            attachment_filename=filename,
+        )
+        return response
+
+    @expose("/import/", methods=["POST"])
+    @protect()
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.import_",
+        log_to_statsd=False,
+    )
+    @requires_form_data
+    def import_(self) -> Response:
+        """Import multiple assets
+        ---
+        post:
+          requestBody:
+            required: true
+            content:
+              multipart/form-data:
+                schema:
+                  type: object
+                  properties:
+                    bundle:
+                      description: upload file (ZIP or JSON)
+                      type: string
+                      format: binary
+                    passwords:
+                      description: >-
+                        JSON map of passwords for each featured database in the
+                        ZIP file. If the ZIP includes a database config in the 
path
+                        `databases/MyDatabase.yaml`, the password should be 
provided
+                        in the following format:
+                        `{"databases/MyDatabase.yaml": "my_password"}`.
+                      type: string
+          responses:
+            200:
+              description: Dashboard import result
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        upload = request.files.get("bundle")
+        if not upload:
+            return self.response_400()
+        if not is_zipfile(upload):
+            raise IncorrectFormatError("Not a ZIP file")
+
+        with ZipFile(upload) as bundle:
+            contents = get_contents_from_bundle(bundle)
+
+        if not contents:
+            raise NoValidFilesFoundError()
+
+        passwords = (
+            json.loads(request.form["passwords"])
+            if "passwords" in request.form
+            else None
+        )
+
+        command = ImportAssetsCommand(contents, passwords=passwords)
+        command.run()
+        return self.response(200, message="OK")
diff --git a/superset/initialization/__init__.py 
b/superset/initialization/__init__.py
index 6e2d927..f6ffd3e 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -67,7 +67,7 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         self.config = app.config
         self.manifest: Dict[Any, Any] = {}
 
-    @deprecated(details="use self.superset_app instead of self.flask_app")  # 
type: ignore   # pylint: disable=line-too-long,useless-suppression
+    @deprecated(details="use self.superset_app instead of self.flask_app")  # 
type: ignore
     @property
     def flask_app(self) -> SupersetApp:
         return self.superset_app
@@ -143,6 +143,7 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         from superset.datasets.metrics.api import DatasetMetricRestApi
         from superset.explore.form_data.api import ExploreFormDataRestApi
         from superset.explore.permalink.api import ExplorePermalinkRestApi
+        from superset.importexport.api import ImportExportRestApi
         from superset.queries.api import QueryRestApi
         from superset.queries.saved_queries.api import SavedQueryRestApi
         from superset.reports.api import ReportScheduleRestApi
@@ -219,6 +220,7 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         appbuilder.add_api(ExploreFormDataRestApi)
         appbuilder.add_api(ExplorePermalinkRestApi)
         appbuilder.add_api(FilterSetRestApi)
+        appbuilder.add_api(ImportExportRestApi)
         appbuilder.add_api(QueryRestApi)
         appbuilder.add_api(ReportScheduleRestApi)
         appbuilder.add_api(ReportExecutionLogRestApi)
diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py
index 8522877..4987aaf 100644
--- a/tests/unit_tests/conftest.py
+++ b/tests/unit_tests/conftest.py
@@ -14,9 +14,10 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# pylint: disable=redefined-outer-name
+# pylint: disable=redefined-outer-name, import-outside-toplevel
 
-from typing import Iterator
+import importlib
+from typing import Any, Iterator
 
 import pytest
 from pytest_mock import MockFixture
@@ -25,11 +26,12 @@ from sqlalchemy.orm import sessionmaker
 from sqlalchemy.orm.session import Session
 
 from superset.app import SupersetApp
+from superset.extensions import appbuilder
 from superset.initialization import SupersetAppInitializer
 
 
[email protected]()
-def session() -> Iterator[Session]:
[email protected]
+def session(mocker: MockFixture) -> Iterator[Session]:
     """
     Create an in-memory SQLite session to test models.
     """
@@ -40,11 +42,18 @@ def session() -> Iterator[Session]:
     # flask calls session.remove()
     in_memory_session.remove = lambda: None
 
+    # patch session
+    mocker.patch(
+        "superset.security.SupersetSecurityManager.get_session",
+        return_value=in_memory_session,
+    )
+    mocker.patch("superset.db.session", in_memory_session)
+
     yield in_memory_session
 
 
[email protected]
-def app(mocker: MockFixture, session: Session) -> Iterator[SupersetApp]:
[email protected](scope="module")
+def app() -> Iterator[SupersetApp]:
     """
     A fixture that generates a Superset app.
     """
@@ -52,21 +61,32 @@ def app(mocker: MockFixture, session: Session) -> 
Iterator[SupersetApp]:
 
     app.config.from_object("superset.config")
     app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://"
-    app.config["FAB_ADD_SECURITY_VIEWS"] = False
+    app.config["TESTING"] = True
 
-    app_initializer = app.config.get("APP_INITIALIZER", 
SupersetAppInitializer)(app)
+    # ``superset.extensions.appbuilder`` is a singleton, and won't rebuild the
+    # routes when this fixture is called multiple times; we need to clear the
+    # registered views to ensure the initialization can happen more than once.
+    appbuilder.baseviews = []
+
+    app_initializer = SupersetAppInitializer(app)
     app_initializer.init_app()
 
-    # patch session
-    mocker.patch(
-        "superset.security.SupersetSecurityManager.get_session", 
return_value=session,
-    )
-    mocker.patch("superset.db.session", session)
+    # reload base views to ensure error handlers are applied to the app
+    with app.app_context():
+        import superset.views.base
+
+        importlib.reload(superset.views.base)
 
     yield app
 
 
 @pytest.fixture
+def client(app: SupersetApp) -> Any:
+    with app.test_client() as client:
+        yield client
+
+
[email protected]
 def app_context(app: SupersetApp) -> Iterator[None]:
     """
     A fixture that yields and application context.
diff --git a/superset/commands/importers/exceptions.py 
b/tests/unit_tests/importexport/__init__.py
similarity index 73%
copy from superset/commands/importers/exceptions.py
copy to tests/unit_tests/importexport/__init__.py
index c1beb8e..13a8339 100644
--- a/superset/commands/importers/exceptions.py
+++ b/tests/unit_tests/importexport/__init__.py
@@ -14,15 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-from superset.commands.exceptions import CommandException
-
-
-class IncorrectVersionError(CommandException):
-    status = 422
-    message = "Import has incorrect version"
-
-
-class NoValidFilesFoundError(CommandException):
-    status = 400
-    message = "No valid import files were found"
diff --git a/tests/unit_tests/importexport/api_test.py 
b/tests/unit_tests/importexport/api_test.py
new file mode 100644
index 0000000..e5dee97
--- /dev/null
+++ b/tests/unit_tests/importexport/api_test.py
@@ -0,0 +1,254 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+# pylint: disable=invalid-name, import-outside-toplevel
+
+import json
+from io import BytesIO
+from pathlib import Path
+from typing import Any
+from zipfile import is_zipfile, ZipFile
+
+from pytest_mock import MockFixture
+
+from superset import security_manager
+
+
+def test_export_assets(mocker: MockFixture, client: Any) -> None:
+    """
+    Test exporting assets.
+    """
+    from superset.commands.importers.v1.utils import get_contents_from_bundle
+
+    # grant access
+    mocker.patch(
+        "flask_appbuilder.security.decorators.verify_jwt_in_request", 
return_value=True
+    )
+    mocker.patch.object(security_manager, "has_access", return_value=True)
+
+    mocked_contents = [
+        (
+            "metadata.yaml",
+            "version: 1.0.0\ntype: assets\ntimestamp: 
'2022-01-01T00:00:00+00:00'\n",
+        ),
+        ("databases/example.yaml", "<DATABASE CONTENTS>"),
+    ]
+
+    ExportAssetsCommand = 
mocker.patch("superset.importexport.api.ExportAssetsCommand")
+    ExportAssetsCommand().run.return_value = mocked_contents[:]
+
+    response = client.get("/api/v1/assets/export/")
+    assert response.status_code == 200
+
+    buf = BytesIO(response.data)
+    assert is_zipfile(buf)
+
+    buf.seek(0)
+    with ZipFile(buf) as bundle:
+        contents = get_contents_from_bundle(bundle)
+    assert contents == dict(mocked_contents)
+
+
+def test_import_assets(mocker: MockFixture, client: Any) -> None:
+    """
+    Test importing assets.
+    """
+    # grant access
+    mocker.patch(
+        "flask_appbuilder.security.decorators.verify_jwt_in_request", 
return_value=True
+    )
+    mocker.patch.object(security_manager, "has_access", return_value=True)
+
+    mocked_contents = {
+        "metadata.yaml": (
+            "version: 1.0.0\ntype: assets\ntimestamp: 
'2022-01-01T00:00:00+00:00'\n"
+        ),
+        "databases/example.yaml": "<DATABASE CONTENTS>",
+    }
+
+    ImportAssetsCommand = 
mocker.patch("superset.importexport.api.ImportAssetsCommand")
+
+    root = Path("assets_export")
+    buf = BytesIO()
+    with ZipFile(buf, "w") as bundle:
+        for path, contents in mocked_contents.items():
+            with bundle.open(str(root / path), "w") as fp:
+                fp.write(contents.encode())
+    buf.seek(0)
+
+    form_data = {
+        "bundle": (buf, "assets_export.zip"),
+        "passwords": json.dumps(
+            {"assets_export/databases/imported_database.yaml": "SECRET"}
+        ),
+    }
+    response = client.post(
+        "/api/v1/assets/import/", data=form_data, 
content_type="multipart/form-data"
+    )
+    assert response.status_code == 200
+    assert response.json == {"message": "OK"}
+
+    passwords = {"assets_export/databases/imported_database.yaml": "SECRET"}
+    ImportAssetsCommand.assert_called_with(mocked_contents, 
passwords=passwords)
+
+
+def test_import_assets_not_zip(mocker: MockFixture, client: Any) -> None:
+    """
+    Test error message when the upload is not a ZIP file.
+    """
+    # grant access
+    mocker.patch(
+        "flask_appbuilder.security.decorators.verify_jwt_in_request", 
return_value=True
+    )
+    mocker.patch.object(security_manager, "has_access", return_value=True)
+
+    buf = BytesIO(b"definitely_not_a_zip_file")
+    form_data = {
+        "bundle": (buf, "broken.txt"),
+    }
+    response = client.post(
+        "/api/v1/assets/import/", data=form_data, 
content_type="multipart/form-data"
+    )
+    assert response.status_code == 422
+    assert response.json == {
+        "errors": [
+            {
+                "message": "Not a ZIP file",
+                "error_type": "GENERIC_COMMAND_ERROR",
+                "level": "warning",
+                "extra": {
+                    "issue_codes": [
+                        {
+                            "code": 1010,
+                            "message": (
+                                "Issue 1010 - Superset encountered an error 
while "
+                                "running a command."
+                            ),
+                        }
+                    ]
+                },
+            }
+        ]
+    }
+
+
+def test_import_assets_no_form_data(mocker: MockFixture, client: Any) -> None:
+    """
+    Test error message when the upload has no form data.
+    """
+    # grant access
+    mocker.patch(
+        "flask_appbuilder.security.decorators.verify_jwt_in_request", 
return_value=True
+    )
+    mocker.patch.object(security_manager, "has_access", return_value=True)
+
+    response = client.post("/api/v1/assets/import/", data="some_content")
+    assert response.status_code == 400
+    assert response.json == {
+        "errors": [
+            {
+                "message": "Request MIME type is not 'multipart/form-data'",
+                "error_type": "INVALID_PAYLOAD_FORMAT_ERROR",
+                "level": "error",
+                "extra": {
+                    "issue_codes": [
+                        {
+                            "code": 1019,
+                            "message": (
+                                "Issue 1019 - The submitted payload has the 
incorrect "
+                                "format."
+                            ),
+                        }
+                    ]
+                },
+            }
+        ]
+    }
+
+
+def test_import_assets_incorrect_form_data(mocker: MockFixture, client: Any) 
-> None:
+    """
+    Test error message when the upload form data has the wrong key.
+    """
+    # grant access
+    mocker.patch(
+        "flask_appbuilder.security.decorators.verify_jwt_in_request", 
return_value=True
+    )
+    mocker.patch.object(security_manager, "has_access", return_value=True)
+
+    buf = BytesIO(b"definitely_not_a_zip_file")
+    form_data = {
+        "wrong": (buf, "broken.txt"),
+    }
+    response = client.post(
+        "/api/v1/assets/import/", data=form_data, 
content_type="multipart/form-data"
+    )
+    assert response.status_code == 400
+    assert response.json == {"message": "Arguments are not correct"}
+
+
+def test_import_assets_no_contents(mocker: MockFixture, client: Any) -> None:
+    """
+    Test error message when the ZIP bundle has no contents.
+    """
+    # grant access
+    mocker.patch(
+        "flask_appbuilder.security.decorators.verify_jwt_in_request", 
return_value=True
+    )
+    mocker.patch.object(security_manager, "has_access", return_value=True)
+
+    mocked_contents = {
+        "README.txt": "Something is wrong",
+    }
+
+    root = Path("assets_export")
+    buf = BytesIO()
+    with ZipFile(buf, "w") as bundle:
+        for path, contents in mocked_contents.items():
+            with bundle.open(str(root / path), "w") as fp:
+                fp.write(contents.encode())
+    buf.seek(0)
+
+    form_data = {
+        "bundle": (buf, "assets_export.zip"),
+        "passwords": json.dumps(
+            {"assets_export/databases/imported_database.yaml": "SECRET"}
+        ),
+    }
+    response = client.post(
+        "/api/v1/assets/import/", data=form_data, 
content_type="multipart/form-data"
+    )
+    assert response.status_code == 400
+    assert response.json == {
+        "errors": [
+            {
+                "message": "No valid import files were found",
+                "error_type": "GENERIC_COMMAND_ERROR",
+                "level": "warning",
+                "extra": {
+                    "issue_codes": [
+                        {
+                            "code": 1010,
+                            "message": (
+                                "Issue 1010 - Superset encountered an error 
while "
+                                "running a command."
+                            ),
+                        }
+                    ]
+                },
+            }
+        ]
+    }
diff --git a/superset/commands/importers/exceptions.py 
b/tests/unit_tests/views/__init__.py
similarity index 73%
copy from superset/commands/importers/exceptions.py
copy to tests/unit_tests/views/__init__.py
index c1beb8e..13a8339 100644
--- a/superset/commands/importers/exceptions.py
+++ b/tests/unit_tests/views/__init__.py
@@ -14,15 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-from superset.commands.exceptions import CommandException
-
-
-class IncorrectVersionError(CommandException):
-    status = 422
-    message = "Import has incorrect version"
-
-
-class NoValidFilesFoundError(CommandException):
-    status = 400
-    message = "No valid import files were found"

Reply via email to