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"