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

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 0a047bd8af2 AIP-68 Add loading validation to React App and External 
Views (#52972)
0a047bd8af2 is described below

commit 0a047bd8af25d1690f84727ade83a4efa9bee522
Author: Pierre Jeambrun <pierrejb...@gmail.com>
AuthorDate: Wed Jul 9 16:37:34 2025 +0200

    AIP-68 Add loading validation to React App and External Views (#52972)
    
    * AIP-68 Add loading validation to React App and External Views
    
    * Small adjustment
---
 airflow-core/src/airflow/api_fastapi/app.py        |  2 ++
 .../src/airflow/api_fastapi/core_api/app.py        |  7 ++++
 airflow-core/src/airflow/plugins_manager.py        | 38 ++++++++++++++++++--
 .../tests/unit/plugins/test_plugins_manager.py     | 42 ++++++++++++++++++++++
 4 files changed, 87 insertions(+), 2 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/app.py 
b/airflow-core/src/airflow/api_fastapi/app.py
index 36b59630d0b..f515be6992c 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -30,6 +30,7 @@ from airflow.api_fastapi.core_api.app import (
     init_error_handlers,
     init_flask_plugins,
     init_middlewares,
+    init_ui_plugins,
     init_views,
 )
 from airflow.api_fastapi.execution_api.app import create_task_execution_api_app
@@ -93,6 +94,7 @@ def create_app(apps: str = "all") -> FastAPI:
         init_plugins(app)
         init_auth_manager(app)
         init_flask_plugins(app)
+        init_ui_plugins(app)
         init_views(app)  # Core views need to be the last routes added - it 
has a catch all route
         init_error_handlers(app)
         init_middlewares(app)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py 
b/airflow-core/src/airflow/api_fastapi/core_api/app.py
index ad8938de994..bafa3add823 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -181,3 +181,10 @@ def init_middlewares(app: FastAPI) -> None:
         from airflow.api_fastapi.auth.managers.simple.middleware import 
SimpleAllAdminMiddleware
 
         app.add_middleware(SimpleAllAdminMiddleware)
+
+
+def init_ui_plugins(app: FastAPI) -> None:
+    """Initialize UI plugins."""
+    from airflow import plugins_manager
+
+    plugins_manager.initialize_ui_plugins()
diff --git a/airflow-core/src/airflow/plugins_manager.py 
b/airflow-core/src/airflow/plugins_manager.py
index 2b0b324e9e3..d750ff7c405 100644
--- a/airflow-core/src/airflow/plugins_manager.py
+++ b/airflow-core/src/airflow/plugins_manager.py
@@ -387,12 +387,46 @@ def initialize_ui_plugins():
 
     log.debug("Initialize UI plugin")
 
+    seen_url_route = {}
     external_views = []
     react_apps = []
 
     for plugin in plugins:
-        external_views.extend(plugin.external_views)
-        react_apps.extend(plugin.react_apps)
+        for external_view in plugin.external_views:
+            url_route = external_view["url_route"]
+            if url_route is not None and url_route in seen_url_route:
+                log.warning(
+                    "Plugin '%s' has an external view with an URL route '%s' "
+                    "that conflicts with another plugin '%s'. The view will 
not be loaded.",
+                    plugin.name,
+                    url_route,
+                    seen_url_route[url_route],
+                )
+                # Mutate in place the plugin's external views to remove the 
conflicting view
+                # because some function still access the plugin's external 
views and not the
+                # global `external_views` variable. (get_plugin_info, for 
example)
+                plugin.external_views.remove(external_view)
+                continue
+            external_views.append(external_view)
+            seen_url_route[url_route] = plugin.name
+
+        for react_app in plugin.react_apps:
+            url_route = react_app["url_route"]
+            if url_route is not None and url_route in seen_url_route:
+                log.warning(
+                    "Plugin '%s' has a React App with an URL route '%s' "
+                    "that conflicts with another plugin '%s'. The React App 
will not be loaded.",
+                    plugin.name,
+                    url_route,
+                    seen_url_route[url_route],
+                )
+                # Mutate in place the plugin's React Apps to remove the 
conflicting app
+                # because some function still access the plugin's React Apps 
and not the
+                # global `react_apps` variable. (get_plugin_info, for example)
+                plugin.react_apps.remove(react_app)
+                continue
+            react_apps.append(react_app)
+            seen_url_route[url_route] = plugin.name
 
 
 def initialize_flask_plugins():
diff --git a/airflow-core/tests/unit/plugins/test_plugins_manager.py 
b/airflow-core/tests/unit/plugins/test_plugins_manager.py
index addcc4745a2..ae65bb2e790 100644
--- a/airflow-core/tests/unit/plugins/test_plugins_manager.py
+++ b/airflow-core/tests/unit/plugins/test_plugins_manager.py
@@ -151,6 +151,48 @@ class TestPluginsManager:
             ),
         ]
 
+    def test_should_warning_about_conflicting_url_route(self, caplog):
+        class TestPluginA(AirflowPlugin):
+            name = "test_plugin_a"
+
+            external_views = [{"url_route": "/test_route"}]
+
+        class TestPluginB(AirflowPlugin):
+            name = "test_plugin_b"
+
+            external_views = [{"url_route": "/test_route"}]
+            react_apps = [{"url_route": "/test_route"}]
+
+        with (
+            mock_plugin_manager(plugins=[TestPluginA(), TestPluginB()]),
+            caplog.at_level(logging.WARNING, logger="airflow.plugins_manager"),
+        ):
+            from airflow import plugins_manager
+
+            plugins_manager.initialize_ui_plugins()
+
+            # Verify that the conflicting external view and react app are not 
loaded
+            plugin_b = next(plugin for plugin in plugins_manager.plugins if 
plugin.name == "test_plugin_b")
+            assert plugin_b.external_views == []
+            assert plugin_b.react_apps == []
+            assert len(plugins_manager.external_views) == 1
+            assert len(plugins_manager.react_apps) == 0
+
+        assert caplog.record_tuples == [
+            (
+                "airflow.plugins_manager",
+                logging.WARNING,
+                "Plugin 'test_plugin_b' has an external view with an URL route 
'/test_route' "
+                "that conflicts with another plugin 'test_plugin_a'. The view 
will not be loaded.",
+            ),
+            (
+                "airflow.plugins_manager",
+                logging.WARNING,
+                "Plugin 'test_plugin_b' has a React App with an URL route 
'/test_route' "
+                "that conflicts with another plugin 'test_plugin_a'. The React 
App will not be loaded.",
+            ),
+        ]
+
     def test_should_not_warning_about_fab_plugins(self, caplog):
         class AirflowAdminViewsPlugin(AirflowPlugin):
             name = "test_admin_views_plugin"

Reply via email to