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 ba0d3cd7733 Support root middleware from plugins (#48678)
ba0d3cd7733 is described below
commit ba0d3cd77331d27f39d7e69748c8a0865daf9b87
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Apr 4 08:07:16 2025 +0200
Support root middleware from plugins (#48678)
* Add support for root middleware from plugins
* Update generated airflow-ctl datamodels
* Fix CI
* Update airflow-ctl/pyproject.toml
* Address comments and Fix CI
* Address comments
* Update airflow-core/docs/administration-and-deployment/plugins.rst
Co-authored-by: Kalyan R <[email protected]>
* Update airflow-core/docs/administration-and-deployment/plugins.rst
Co-authored-by: Kalyan R <[email protected]>
---------
Co-authored-by: Kalyan R <[email protected]>
---
.../docs/administration-and-deployment/plugins.rst | 15 ++++++++-
airflow-core/src/airflow/api_fastapi/app.py | 38 ++++++++++++++++++++--
.../src/airflow/api_fastapi/core_api/app.py | 23 -------------
.../api_fastapi/core_api/datamodels/plugins.py | 10 ++++++
.../api_fastapi/core_api/openapi/v1-generated.yaml | 21 ++++++++++++
airflow-core/src/airflow/plugins_manager.py | 20 ++++++++++--
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 26 +++++++++++++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 10 ++++++
.../unit/cli/commands/test_plugins_command.py | 6 ++++
airflow-core/tests/unit/plugins/test_plugin.py | 15 +++++++++
.../src/airflowctl/api/datamodels/generated.py | 19 +++++++++++
.../tests/airflow_ctl/api/test_operations.py | 4 +++
.../src/tests_common/test_utils/mock_plugins.py | 1 +
13 files changed, 180 insertions(+), 28 deletions(-)
diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst
b/airflow-core/docs/administration-and-deployment/plugins.rst
index 7f2ef7d4a7a..47c0f2fecdf 100644
--- a/airflow-core/docs/administration-and-deployment/plugins.rst
+++ b/airflow-core/docs/administration-and-deployment/plugins.rst
@@ -106,8 +106,10 @@ looks like:
macros = []
# A list of Blueprint object created from flask.Blueprint. For use
with the flask_appbuilder based GUI
flask_blueprints = []
- # A list of dictionaries contanning FastAPI object and some metadata.
See example below.
+ # A list of dictionaries containing FastAPI app objects and some
metadata. See the example below.
fastapi_apps = []
+ # A list of dictionaries containing FastAPI middleware factory objects
and some metadata. See the example below.
+ fastapi_root_middlewares = []
# A list of dictionaries containing FlaskAppBuilder BaseView object
and some metadata. See example below
appbuilder_views = []
# A list of dictionaries containing kwargs for FlaskAppBuilder
add_link. See example below
@@ -166,6 +168,7 @@ definitions in Airflow.
from airflow.providers.fab.www.auth import has_access
from fastapi import FastAPI
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
from flask import Blueprint
from flask_appbuilder import expose, BaseView as AppBuilderBaseView
@@ -201,6 +204,15 @@ definitions in Airflow.
app_with_metadata = {"app": app, "url_prefix": "/some_prefix", "name":
"Name of the App"}
+ # Creating a FastAPI middleware that will operates on all the server api
requests.
+ middleware_with_metadata = {
+ "middleware": TrustedHostMiddleware,
+ "args": [],
+ "kwargs": {"allowed_hosts": ["example.com", "*.example.com"]},
+ "name": "Name of the Middleware",
+ }
+
+
# Creating a flask appbuilder BaseView
class TestAppBuilderBaseView(AppBuilderBaseView):
default_view = "test"
@@ -257,6 +269,7 @@ definitions in Airflow.
macros = [plugin_macro]
flask_blueprints = [bp]
fastapi_apps = [app_with_metadata]
+ fastapi_root_middlewares = [middleware_with_metadata]
appbuilder_views = [v_appbuilder_package, v_appbuilder_nomenu_package]
appbuilder_menu_items = [appbuilder_mitem, appbuilder_mitem_toplevel]
diff --git a/airflow-core/src/airflow/api_fastapi/app.py
b/airflow-core/src/airflow/api_fastapi/app.py
index 4ef7ab65113..28c66cabcfc 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import os
from contextlib import AsyncExitStack, asynccontextmanager
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
from urllib.parse import urlsplit
from fastapi import FastAPI
@@ -31,7 +31,6 @@ from airflow.api_fastapi.core_api.app import (
init_error_handlers,
init_flask_plugins,
init_middlewares,
- init_plugins,
init_views,
)
from airflow.api_fastapi.execution_api.app import create_task_execution_api_app
@@ -160,3 +159,38 @@ def get_auth_manager() -> BaseAuthManager:
"The `init_auth_manager` method needs to be called first."
)
return auth_manager
+
+
+def init_plugins(app: FastAPI) -> None:
+ """Integrate FastAPI app and middleware plugins."""
+ from airflow import plugins_manager
+
+ plugins_manager.initialize_fastapi_plugins()
+
+ # After calling initialize_fastapi_plugins, fastapi_apps cannot be None
anymore.
+ for subapp_dict in cast("list", plugins_manager.fastapi_apps):
+ name = subapp_dict.get("name")
+ subapp = subapp_dict.get("app")
+ if subapp is None:
+ log.error("'app' key is missing for the fastapi app: %s", name)
+ continue
+ url_prefix = subapp_dict.get("url_prefix")
+ if url_prefix is None:
+ log.error("'url_prefix' key is missing for the fastapi app: %s",
name)
+ continue
+
+ log.debug("Adding subapplication %s under prefix %s", name, url_prefix)
+ app.mount(url_prefix, subapp)
+
+ for middleware_dict in cast("list",
plugins_manager.fastapi_root_middlewares):
+ name = middleware_dict.get("name")
+ middleware = middleware_dict.get("middleware")
+ args = middleware_dict.get("args", [])
+ kwargs = middleware_dict.get("kwargs", {})
+
+ if middleware is None:
+ log.error("'middleware' key is missing for the fastapi middleware:
%s", name)
+ continue
+
+ log.debug("Adding root middleware %s", name)
+ app.add_middleware(middleware, *args, **kwargs)
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 9e62aaeae36..22ac4aba56e 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -20,7 +20,6 @@ import logging
import os
import warnings
from pathlib import Path
-from typing import cast
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -85,28 +84,6 @@ def init_views(app: FastAPI) -> None:
)
-def init_plugins(app: FastAPI) -> None:
- """Integrate FastAPI app plugins."""
- from airflow import plugins_manager
-
- plugins_manager.initialize_fastapi_plugins()
-
- # After calling initialize_fastapi_plugins, fastapi_apps cannot be None
anymore.
- for subapp_dict in cast("list", plugins_manager.fastapi_apps):
- name = subapp_dict.get("name")
- subapp = subapp_dict.get("app")
- if subapp is None:
- log.error("'app' key is missing for the fastapi app: %s", name)
- continue
- url_prefix = subapp_dict.get("url_prefix")
- if url_prefix is None:
- log.error("'url_prefix' key is missing for the fastapi app: %s",
name)
- continue
-
- log.debug("Adding subapplication %s under prefix %s", name, url_prefix)
- app.mount(url_prefix, subapp)
-
-
def init_flask_plugins(app: FastAPI) -> None:
"""Integrate Flask plugins (plugins from Airflow 2)."""
from airflow import plugins_manager
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
index d36e5faf851..1aab230ac3d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
@@ -39,6 +39,15 @@ class FastAPIAppResponse(BaseModel):
name: str
+class FastAPIRootMiddlewareResponse(BaseModel):
+ """Serializer for Plugin FastAPI root middleware responses."""
+
+ model_config = ConfigDict(extra="allow")
+
+ middleware: str
+ name: str
+
+
class AppBuilderViewResponse(BaseModel):
"""Serializer for AppBuilder View responses."""
@@ -67,6 +76,7 @@ class PluginResponse(BaseModel):
macros: list[str]
flask_blueprints: list[str]
fastapi_apps: list[FastAPIAppResponse]
+ fastapi_root_middlewares: list[FastAPIRootMiddlewareResponse]
appbuilder_views: list[AppBuilderViewResponse]
appbuilder_menu_items: list[AppBuilderMenuItemResponse]
global_operator_extra_links: list[str]
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
index 7f06a982d9a..c01df4277d4 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
@@ -9826,6 +9826,21 @@ components:
- name
title: FastAPIAppResponse
description: Serializer for Plugin FastAPI App responses.
+ FastAPIRootMiddlewareResponse:
+ properties:
+ middleware:
+ type: string
+ title: Middleware
+ name:
+ type: string
+ title: Name
+ additionalProperties: true
+ type: object
+ required:
+ - middleware
+ - name
+ title: FastAPIRootMiddlewareResponse
+ description: Serializer for Plugin FastAPI root middleware responses.
GridDAGRunwithTIs:
properties:
dag_run_id:
@@ -10319,6 +10334,11 @@ components:
$ref: '#/components/schemas/FastAPIAppResponse'
type: array
title: Fastapi Apps
+ fastapi_root_middlewares:
+ items:
+ $ref: '#/components/schemas/FastAPIRootMiddlewareResponse'
+ type: array
+ title: Fastapi Root Middlewares
appbuilder_views:
items:
$ref: '#/components/schemas/AppBuilderViewResponse'
@@ -10358,6 +10378,7 @@ components:
- macros
- flask_blueprints
- fastapi_apps
+ - fastapi_root_middlewares
- appbuilder_views
- appbuilder_menu_items
- global_operator_extra_links
diff --git a/airflow-core/src/airflow/plugins_manager.py
b/airflow-core/src/airflow/plugins_manager.py
index 98d8ce02363..34d8f100d75 100644
--- a/airflow-core/src/airflow/plugins_manager.py
+++ b/airflow-core/src/airflow/plugins_manager.py
@@ -68,6 +68,7 @@ macros_modules: list[Any] | None = None
admin_views: list[Any] | None = None
flask_blueprints: list[Any] | None = None
fastapi_apps: list[Any] | None = None
+fastapi_root_middlewares: list[Any] | None = None
menu_links: list[Any] | None = None
flask_appbuilder_views: list[Any] | None = None
flask_appbuilder_menu_links: list[Any] | None = None
@@ -88,6 +89,7 @@ PLUGINS_ATTRIBUTES_TO_DUMP = {
"admin_views",
"flask_blueprints",
"fastapi_apps",
+ "fastapi_root_middlewares",
"menu_links",
"appbuilder_views",
"appbuilder_menu_items",
@@ -151,6 +153,7 @@ class AirflowPlugin:
admin_views: list[Any] = []
flask_blueprints: list[Any] = []
fastapi_apps: list[Any] = []
+ fastapi_root_middlewares: list[Any] = []
menu_links: list[Any] = []
appbuilder_views: list[Any] = []
appbuilder_menu_items: list[Any] = []
@@ -406,8 +409,9 @@ def initialize_fastapi_plugins():
"""Collect extension points for the API."""
global plugins
global fastapi_apps
+ global fastapi_root_middlewares
- if fastapi_apps:
+ if fastapi_apps is not None and fastapi_root_middlewares is not None:
return
ensure_plugins_loaded()
@@ -415,12 +419,14 @@ def initialize_fastapi_plugins():
if plugins is None:
raise AirflowPluginException("Can't load plugins.")
- log.debug("Initialize FastAPI plugin")
+ log.debug("Initialize FastAPI plugins")
fastapi_apps = []
+ fastapi_root_middlewares = []
for plugin in plugins:
fastapi_apps.extend(plugin.fastapi_apps)
+ fastapi_root_middlewares.extend(plugin.fastapi_root_middlewares)
def initialize_extra_operators_links_plugins():
@@ -586,6 +592,16 @@ def get_plugin_info(attrs_to_dump: Iterable[str] | None =
None) -> list[dict[str
{**d, "app": qualname(d["app"].__class__) if "app" in
d else None}
for d in getattr(plugin, attr)
]
+ elif attr == "fastapi_root_middlewares":
+ # remove args and kwargs from plugin info to hide
potentially sensitive info.
+ info[attr] = [
+ {
+ k: (v if k != "middleware" else
qualname(middleware_dict["middleware"]))
+ for k, v in middleware_dict.items()
+ if k not in ("args", "kwargs")
+ }
+ for middleware_dict in getattr(plugin, attr)
+ ]
else:
info[attr] = getattr(plugin, attr)
plugins_info.append(info)
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 4718a06f348..3e231b9f4ea 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -3715,6 +3715,24 @@ export const $FastAPIAppResponse = {
description: "Serializer for Plugin FastAPI App responses.",
} as const;
+export const $FastAPIRootMiddlewareResponse = {
+ properties: {
+ middleware: {
+ type: "string",
+ title: "Middleware",
+ },
+ name: {
+ type: "string",
+ title: "Name",
+ },
+ },
+ additionalProperties: true,
+ type: "object",
+ required: ["middleware", "name"],
+ title: "FastAPIRootMiddlewareResponse",
+ description: "Serializer for Plugin FastAPI root middleware responses.",
+} as const;
+
export const $GridDAGRunwithTIs = {
properties: {
dag_run_id: {
@@ -4468,6 +4486,13 @@ export const $PluginResponse = {
type: "array",
title: "Fastapi Apps",
},
+ fastapi_root_middlewares: {
+ items: {
+ $ref: "#/components/schemas/FastAPIRootMiddlewareResponse",
+ },
+ type: "array",
+ title: "Fastapi Root Middlewares",
+ },
appbuilder_views: {
items: {
$ref: "#/components/schemas/AppBuilderViewResponse",
@@ -4521,6 +4546,7 @@ export const $PluginResponse = {
"macros",
"flask_blueprints",
"fastapi_apps",
+ "fastapi_root_middlewares",
"appbuilder_views",
"appbuilder_menu_items",
"global_operator_extra_links",
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 91118d67f52..f938fc4ac56 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -981,6 +981,15 @@ export type FastAPIAppResponse = {
[key: string]: unknown | string;
};
+/**
+ * Serializer for Plugin FastAPI root middleware responses.
+ */
+export type FastAPIRootMiddlewareResponse = {
+ middleware: string;
+ name: string;
+ [key: string]: unknown | string;
+};
+
/**
* DAG Run model for the Grid UI.
*/
@@ -1160,6 +1169,7 @@ export type PluginResponse = {
macros: Array<string>;
flask_blueprints: Array<string>;
fastapi_apps: Array<FastAPIAppResponse>;
+ fastapi_root_middlewares: Array<FastAPIRootMiddlewareResponse>;
appbuilder_views: Array<AppBuilderViewResponse>;
appbuilder_menu_items: Array<AppBuilderMenuItemResponse>;
global_operator_extra_links: Array<string>;
diff --git a/airflow-core/tests/unit/cli/commands/test_plugins_command.py
b/airflow-core/tests/unit/cli/commands/test_plugins_command.py
index 1f7278d9091..b1c0e705a67 100644
--- a/airflow-core/tests/unit/cli/commands/test_plugins_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_plugins_command.py
@@ -84,6 +84,12 @@ class TestPluginsCommand:
"name": "Name of the App",
}
],
+ "fastapi_root_middlewares": [
+ {
+ "middleware":
"unit.plugins.test_plugin.DummyMiddleware",
+ "name": "Name of the Middleware",
+ }
+ ],
"appbuilder_views": [
{
"name": "Test View",
diff --git a/airflow-core/tests/unit/plugins/test_plugin.py
b/airflow-core/tests/unit/plugins/test_plugin.py
index 9671a20f0f9..9c75bc3fbb9 100644
--- a/airflow-core/tests/unit/plugins/test_plugin.py
+++ b/airflow-core/tests/unit/plugins/test_plugin.py
@@ -20,6 +20,7 @@ from __future__ import annotations
from fastapi import FastAPI
from flask import Blueprint
from flask_appbuilder import BaseView as AppBuilderBaseView, expose
+from starlette.middleware.base import BaseHTTPMiddleware
# This is the class you derive to create a plugin
from airflow.plugins_manager import AirflowPlugin
@@ -89,6 +90,19 @@ app = FastAPI()
app_with_metadata = {"app": app, "url_prefix": "/some_prefix", "name": "Name
of the App"}
+class DummyMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request, call_next):
+ return await call_next(request)
+
+
+middleware_with_metadata = {
+ "middleware": DummyMiddleware,
+ "args": [],
+ "kwargs": {},
+ "name": "Name of the Middleware",
+}
+
+
# Extend an existing class to avoid the need to implement the full interface
class CustomCronDataIntervalTimetable(CronDataIntervalTimetable):
pass
@@ -105,6 +119,7 @@ class AirflowTestPlugin(AirflowPlugin):
macros = [plugin_macro]
flask_blueprints = [bp]
fastapi_apps = [app_with_metadata]
+ fastapi_root_middlewares = [middleware_with_metadata]
appbuilder_views = [v_appbuilder_package]
appbuilder_menu_items = [appbuilder_mitem, appbuilder_mitem_toplevel]
global_operator_extra_links = [
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 1c332327ec8..c9fe8aaa0ae 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -516,6 +516,18 @@ class FastAPIAppResponse(BaseModel):
name: Annotated[str, Field(title="Name")]
+class FastAPIRootMiddlewareResponse(BaseModel):
+ """
+ Serializer for Plugin FastAPI root middleware responses.
+ """
+
+ model_config = ConfigDict(
+ extra="allow",
+ )
+ middleware: Annotated[str, Field(title="Middleware")]
+ name: Annotated[str, Field(title="Name")]
+
+
class HTTPExceptionResponse(BaseModel):
"""
HTTPException Model used for error response.
@@ -562,6 +574,9 @@ class PluginResponse(BaseModel):
macros: Annotated[list[str], Field(title="Macros")]
flask_blueprints: Annotated[list[str], Field(title="Flask Blueprints")]
fastapi_apps: Annotated[list[FastAPIAppResponse], Field(title="Fastapi
Apps")]
+ fastapi_root_middlewares: Annotated[
+ list[FastAPIRootMiddlewareResponse], Field(title="Fastapi Root
Middlewares")
+ ]
appbuilder_views: Annotated[list[AppBuilderViewResponse],
Field(title="Appbuilder Views")]
appbuilder_menu_items: Annotated[list[AppBuilderMenuItemResponse],
Field(title="Appbuilder Menu Items")]
global_operator_extra_links: Annotated[list[str], Field(title="Global
Operator Extra Links")]
@@ -1086,6 +1101,8 @@ class DAGDetailsResponse(BaseModel):
is_active: Annotated[bool, Field(title="Is Active")]
last_parsed_time: Annotated[datetime | None, Field(title="Last Parsed
Time")] = None
last_expired: Annotated[datetime | None, Field(title="Last Expired")] =
None
+ bundle_name: Annotated[str, Field(title="Bundle Name")]
+ relative_fileloc: Annotated[str, Field(title="Relative Fileloc")]
fileloc: Annotated[str, Field(title="Fileloc")]
description: Annotated[str | None, Field(title="Description")] = None
timetable_summary: Annotated[str | None, Field(title="Timetable Summary")]
= None
@@ -1137,6 +1154,8 @@ class DAGResponse(BaseModel):
is_active: Annotated[bool, Field(title="Is Active")]
last_parsed_time: Annotated[datetime | None, Field(title="Last Parsed
Time")] = None
last_expired: Annotated[datetime | None, Field(title="Last Expired")] =
None
+ bundle_name: Annotated[str, Field(title="Bundle Name")]
+ relative_fileloc: Annotated[str, Field(title="Relative Fileloc")]
fileloc: Annotated[str, Field(title="Fileloc")]
description: Annotated[str | None, Field(title="Description")] = None
timetable_summary: Annotated[str | None, Field(title="Timetable Summary")]
= None
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index c372bd6bbf2..c8f50749c02 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -354,6 +354,7 @@ class TestDagOperations:
last_parsed_time=datetime.datetime(2024, 12, 31, 23, 59, 59),
last_expired=datetime.datetime(2025, 1, 1, 0, 0, 0),
fileloc="fileloc",
+ relative_fileloc="relative_fileloc",
description="description",
timetable_summary="timetable_summary",
timetable_description="timetable_description",
@@ -369,6 +370,7 @@ class TestDagOperations:
next_dagrun_run_after=datetime.datetime(2025, 1, 1, 0, 0, 0),
owners=["apache-airflow"],
file_token="file_token",
+ bundle_name="bundle_name",
)
dag_details_response = DAGDetailsResponse(
@@ -379,6 +381,7 @@ class TestDagOperations:
last_parsed_time=datetime.datetime(2024, 12, 31, 23, 59, 59),
last_expired=datetime.datetime(2025, 1, 1, 0, 0, 0),
fileloc="fileloc",
+ relative_fileloc="relative_fileloc",
description="description",
timetable_summary="timetable_summary",
timetable_description="timetable_description",
@@ -407,6 +410,7 @@ class TestDagOperations:
last_parsed=datetime.datetime(2024, 12, 31, 23, 59, 59),
file_token="file_token",
concurrency=1,
+ bundle_name="bundle_name",
)
def test_get(self):
diff --git a/devel-common/src/tests_common/test_utils/mock_plugins.py
b/devel-common/src/tests_common/test_utils/mock_plugins.py
index 0a5e199fcc0..b522159e5a0 100644
--- a/devel-common/src/tests_common/test_utils/mock_plugins.py
+++ b/devel-common/src/tests_common/test_utils/mock_plugins.py
@@ -27,6 +27,7 @@ PLUGINS_MANAGER_NULLABLE_ATTRIBUTES = [
"admin_views",
"flask_blueprints",
"fastapi_apps",
+ "fastapi_root_middlewares",
"menu_links",
"flask_appbuilder_views",
"flask_appbuilder_menu_links",