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

ephraimanierobi pushed a commit to branch v2-7-test
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 07790488d47110a758d635a7445f4664a97cbe7c
Author: Ephraim Anierobi <splendidzig...@gmail.com>
AuthorDate: Thu Oct 12 12:56:42 2023 +0100

    REST API: Fix wrong plugin schema (#34858)
    
    * REST API: Fix wrong plugin schema
    
    We serialize some plugin's fields as dictionaries leading to errors
    when accessing the `/plugins` endpoint.
    
    Here's the error:
    ValueError: dictionary update sequence element #0 has length 1; 2 is 
required
    
    The fields are lists of strings and this PR addresses it.
    
    * fixup! REST API: Fix wrong plugin schema
    
    * Add test at the endpoint
    
    * fixup! Add test at the endpoint
    
    (cherry picked from commit 474fa4ddfda231f336a2ae7b43fcbd3e349be5c9)
---
 airflow/api_connexion/openapi/v1.yaml              |  8 +-
 airflow/api_connexion/schemas/plugin_schema.py     |  8 +-
 airflow/www/static/js/types/api-generated.ts       |  8 +-
 .../endpoints/test_plugin_endpoint.py              | 64 +++++++++++++--
 tests/api_connexion/schemas/test_plugin_schema.py  | 90 ++++++++++++++++------
 5 files changed, 135 insertions(+), 43 deletions(-)

diff --git a/airflow/api_connexion/openapi/v1.yaml 
b/airflow/api_connexion/openapi/v1.yaml
index b94fe05b59..a696e586fa 100644
--- a/airflow/api_connexion/openapi/v1.yaml
+++ b/airflow/api_connexion/openapi/v1.yaml
@@ -3770,13 +3770,13 @@ components:
         macros:
           type: array
           items:
-            type: object
+            type: string
             nullable: true
           description: The plugin macros
         flask_blueprints:
           type: array
           items:
-            type: object
+            type: string
             nullable: true
           description: The flask blueprints
         appbuilder_views:
@@ -3794,13 +3794,13 @@ components:
         global_operator_extra_links:
           type: array
           items:
-            type: object
+            type: string
             nullable: true
           description: The global operator extra links
         operator_extra_links:
           type: array
           items:
-            type: object
+            type: string
             nullable: true
           description: Operator extra links
         source:
diff --git a/airflow/api_connexion/schemas/plugin_schema.py 
b/airflow/api_connexion/schemas/plugin_schema.py
index 780fef17bf..4b62111482 100644
--- a/airflow/api_connexion/schemas/plugin_schema.py
+++ b/airflow/api_connexion/schemas/plugin_schema.py
@@ -27,12 +27,12 @@ class PluginSchema(Schema):
     name = fields.String()
     hooks = fields.List(fields.String())
     executors = fields.List(fields.String())
-    macros = fields.List(fields.Dict())
-    flask_blueprints = fields.List(fields.Dict())
+    macros = fields.List(fields.String())
+    flask_blueprints = fields.List(fields.String())
     appbuilder_views = fields.List(fields.Dict())
     appbuilder_menu_items = fields.List(fields.Dict())
-    global_operator_extra_links = fields.List(fields.Dict())
-    operator_extra_links = fields.List(fields.Dict())
+    global_operator_extra_links = fields.List(fields.String())
+    operator_extra_links = fields.List(fields.String())
     source = fields.String()
 
 
diff --git a/airflow/www/static/js/types/api-generated.ts 
b/airflow/www/static/js/types/api-generated.ts
index 9477c87db3..01f45ecd8b 100644
--- a/airflow/www/static/js/types/api-generated.ts
+++ b/airflow/www/static/js/types/api-generated.ts
@@ -1575,17 +1575,17 @@ export interface components {
       /** @description The plugin executors */
       executors?: (string | null)[];
       /** @description The plugin macros */
-      macros?: ({ [key: string]: unknown } | null)[];
+      macros?: (string | null)[];
       /** @description The flask blueprints */
-      flask_blueprints?: ({ [key: string]: unknown } | null)[];
+      flask_blueprints?: (string | null)[];
       /** @description The appuilder views */
       appbuilder_views?: ({ [key: string]: unknown } | null)[];
       /** @description The Flask Appbuilder menu items */
       appbuilder_menu_items?: ({ [key: string]: unknown } | null)[];
       /** @description The global operator extra links */
-      global_operator_extra_links?: ({ [key: string]: unknown } | null)[];
+      global_operator_extra_links?: (string | null)[];
       /** @description Operator extra links */
-      operator_extra_links?: ({ [key: string]: unknown } | null)[];
+      operator_extra_links?: (string | null)[];
       /** @description The plugin source */
       source?: string | null;
     };
diff --git a/tests/api_connexion/endpoints/test_plugin_endpoint.py 
b/tests/api_connexion/endpoints/test_plugin_endpoint.py
index 26a4ea0aed..a6f67ab5a7 100644
--- a/tests/api_connexion/endpoints/test_plugin_endpoint.py
+++ b/tests/api_connexion/endpoints/test_plugin_endpoint.py
@@ -17,14 +17,60 @@
 from __future__ import annotations
 
 import pytest
+from flask import Blueprint
+from flask_appbuilder import BaseView
 
+from airflow.hooks.base import BaseHook
+from airflow.models.baseoperator import BaseOperatorLink
 from airflow.plugins_manager import AirflowPlugin
 from airflow.security import permissions
+from airflow.utils.module_loading import qualname
 from tests.test_utils.api_connexion_utils import assert_401, create_user, 
delete_user
 from tests.test_utils.config import conf_vars
 from tests.test_utils.mock_plugins import mock_plugin_manager
 
 
+class PluginHook(BaseHook):
+    ...
+
+
+def plugin_macro():
+    ...
+
+
+class MockOperatorLink(BaseOperatorLink):
+    name = "mock_operator_link"
+
+    def get_link(self, operator, *, ti_key) -> str:
+        return "mock_operator_link"
+
+
+bp = Blueprint("mock_blueprint", __name__, url_prefix="/mock_blueprint")
+
+
+class MockView(BaseView):
+    ...
+
+
+mockview = MockView()
+
+appbuilder_menu_items = {
+    "name": "mock_plugin",
+    "href": "https://example.com";,
+}
+
+
+class MockPlugin(AirflowPlugin):
+    name = "mock_plugin"
+    flask_blueprints = [bp]
+    appbuilder_views = [{"view": mockview}]
+    appbuilder_menu_items = [appbuilder_menu_items]
+    global_operator_extra_links = [MockOperatorLink()]
+    operator_extra_links = [MockOperatorLink()]
+    hooks = [PluginHook]
+    macros = [plugin_macro]
+
+
 @pytest.fixture(scope="module")
 def configured_app(minimal_app_for_api):
     app = minimal_app_for_api
@@ -54,7 +100,7 @@ class TestPluginsEndpoint:
 
 class TestGetPlugins(TestPluginsEndpoint):
     def test_get_plugins_return_200(self):
-        mock_plugin = AirflowPlugin()
+        mock_plugin = MockPlugin()
         mock_plugin.name = "test_plugin"
         with mock_plugin_manager(plugins=[mock_plugin]):
             response = self.client.get("api/v1/plugins", 
environ_overrides={"REMOTE_USER": "test"})
@@ -62,14 +108,16 @@ class TestGetPlugins(TestPluginsEndpoint):
         assert response.json == {
             "plugins": [
                 {
-                    "appbuilder_menu_items": [],
-                    "appbuilder_views": [],
+                    "appbuilder_menu_items": [appbuilder_menu_items],
+                    "appbuilder_views": [{"view": qualname(MockView)}],
                     "executors": [],
-                    "flask_blueprints": [],
-                    "global_operator_extra_links": [],
-                    "hooks": [],
-                    "macros": [],
-                    "operator_extra_links": [],
+                    "flask_blueprints": [
+                        f"<{qualname(bp.__class__)}: name={bp.name!r} 
import_name={bp.import_name!r}>"
+                    ],
+                    "global_operator_extra_links": 
[f"<{qualname(MockOperatorLink().__class__)} object>"],
+                    "hooks": [qualname(PluginHook)],
+                    "macros": [qualname(plugin_macro)],
+                    "operator_extra_links": 
[f"<{qualname(MockOperatorLink().__class__)} object>"],
                     "source": None,
                     "name": "test_plugin",
                 }
diff --git a/tests/api_connexion/schemas/test_plugin_schema.py 
b/tests/api_connexion/schemas/test_plugin_schema.py
index 2366fe7ea3..179a318fe5 100644
--- a/tests/api_connexion/schemas/test_plugin_schema.py
+++ b/tests/api_connexion/schemas/test_plugin_schema.py
@@ -16,20 +16,64 @@
 # under the License.
 from __future__ import annotations
 
+from flask import Blueprint
+from flask_appbuilder import BaseView
+
 from airflow.api_connexion.schemas.plugin_schema import (
     PluginCollection,
     plugin_collection_schema,
     plugin_schema,
 )
+from airflow.hooks.base import BaseHook
+from airflow.models.baseoperator import BaseOperatorLink
 from airflow.plugins_manager import AirflowPlugin
 
 
+class PluginHook(BaseHook):
+    ...
+
+
+def plugin_macro():
+    ...
+
+
+class MockOperatorLink(BaseOperatorLink):
+    name = "mock_operator_link"
+
+    def get_link(self, operator, *, ti_key) -> str:
+        return "mock_operator_link"
+
+
+bp = Blueprint("mock_blueprint", __name__, url_prefix="/mock_blueprint")
+
+
+class MockView(BaseView):
+    ...
+
+
+appbuilder_menu_items = {
+    "name": "mock_plugin",
+    "href": "https://example.com";,
+}
+
+
+class MockPlugin(AirflowPlugin):
+    name = "mock_plugin"
+    flask_blueprints = [bp]
+    appbuilder_views = [{"view": MockView()}]
+    appbuilder_menu_items = [appbuilder_menu_items]
+    global_operator_extra_links = [MockOperatorLink()]
+    operator_extra_links = [MockOperatorLink()]
+    hooks = [PluginHook]
+    macros = [plugin_macro]
+
+
 class TestPluginBase:
     def setup_method(self) -> None:
-        self.mock_plugin = AirflowPlugin()
+        self.mock_plugin = MockPlugin()
         self.mock_plugin.name = "test_plugin"
 
-        self.mock_plugin_2 = AirflowPlugin()
+        self.mock_plugin_2 = MockPlugin()
         self.mock_plugin_2.name = "test_plugin_2"
 
 
@@ -37,14 +81,14 @@ class TestPluginSchema(TestPluginBase):
     def test_serialize(self):
         deserialized_plugin = plugin_schema.dump(self.mock_plugin)
         assert deserialized_plugin == {
-            "appbuilder_menu_items": [],
-            "appbuilder_views": [],
+            "appbuilder_menu_items": [appbuilder_menu_items],
+            "appbuilder_views": [{"view": 
self.mock_plugin.appbuilder_views[0]["view"]}],
             "executors": [],
-            "flask_blueprints": [],
-            "global_operator_extra_links": [],
-            "hooks": [],
-            "macros": [],
-            "operator_extra_links": [],
+            "flask_blueprints": [str(bp)],
+            "global_operator_extra_links": [str(MockOperatorLink())],
+            "hooks": [str(PluginHook)],
+            "macros": [str(plugin_macro)],
+            "operator_extra_links": [str(MockOperatorLink())],
             "source": None,
             "name": "test_plugin",
         }
@@ -58,26 +102,26 @@ class TestPluginCollectionSchema(TestPluginBase):
         assert deserialized == {
             "plugins": [
                 {
-                    "appbuilder_menu_items": [],
-                    "appbuilder_views": [],
+                    "appbuilder_menu_items": [appbuilder_menu_items],
+                    "appbuilder_views": [{"view": 
self.mock_plugin.appbuilder_views[0]["view"]}],
                     "executors": [],
-                    "flask_blueprints": [],
-                    "global_operator_extra_links": [],
-                    "hooks": [],
-                    "macros": [],
-                    "operator_extra_links": [],
+                    "flask_blueprints": [str(bp)],
+                    "global_operator_extra_links": [str(MockOperatorLink())],
+                    "hooks": [str(PluginHook)],
+                    "macros": [str(plugin_macro)],
+                    "operator_extra_links": [str(MockOperatorLink())],
                     "source": None,
                     "name": "test_plugin",
                 },
                 {
-                    "appbuilder_menu_items": [],
-                    "appbuilder_views": [],
+                    "appbuilder_menu_items": [appbuilder_menu_items],
+                    "appbuilder_views": [{"view": 
self.mock_plugin.appbuilder_views[0]["view"]}],
                     "executors": [],
-                    "flask_blueprints": [],
-                    "global_operator_extra_links": [],
-                    "hooks": [],
-                    "macros": [],
-                    "operator_extra_links": [],
+                    "flask_blueprints": [str(bp)],
+                    "global_operator_extra_links": [str(MockOperatorLink())],
+                    "hooks": [str(PluginHook)],
+                    "macros": [str(plugin_macro)],
+                    "operator_extra_links": [str(MockOperatorLink())],
                     "source": None,
                     "name": "test_plugin_2",
                 },

Reply via email to