diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index e5f950fc47b..a550d11cc6c 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -464,7 +464,13 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_view_no_menu(RedirectView) appbuilder.add_view_no_menu(RoleRestAPI) appbuilder.add_view_no_menu(UserInfoView) - if self.config.get("FAB_API_SWAGGER_UI_SUPERSET_APP_ROOT", False): + # Only register the APPLICATION_ROOT-aware Swagger UI / OpenAPI spec when + # Swagger is enabled globally (``FAB_API_SWAGGER_UI``). This preserves the + # global disable contract so operators who turn Swagger off don't get the + # API documentation re-exposed by the prefix-aware variant. + if self.config.get("FAB_API_SWAGGER_UI") and self.config.get( + "FAB_API_SWAGGER_UI_SUPERSET_APP_ROOT", False + ): appbuilder.add_api(SupersetOpenApi) appbuilder.add_view_no_menu(SupersetSwaggerView) @@ -915,6 +921,17 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.indexview = SupersetIndexView appbuilder.security_manager_class = custom_sm + + # The APPLICATION_ROOT-aware Swagger UI and OpenAPI spec replace FAB's + # default views at the same routes (``/api//_openapi`` and + # ``/swagger/``). Suppress FAB's default registration so the two + # implementations don't create duplicate URL rules for the same path, + # which would otherwise leave FAB's (non-prefix-aware) handler in charge. + if self.config.get("FAB_API_SWAGGER_UI") and self.config.get( + "FAB_API_SWAGGER_UI_SUPERSET_APP_ROOT", False + ): + self.superset_app.config["FAB_ADD_OPENAPI_VIEWS"] = False + appbuilder.init_app(self.superset_app, db.session) def configure_url_map_converters(self) -> None: diff --git a/superset/openapi/manager.py b/superset/openapi/manager.py index b233a437305..7bc6a1a04db 100644 --- a/superset/openapi/manager.py +++ b/superset/openapi/manager.py @@ -34,6 +34,20 @@ from flask_appbuilder.security.decorators import has_access from superset.superset_typing import FlaskResponse +def normalize_app_root(app_root: str | None) -> str: + """Normalize ``APPLICATION_ROOT`` into a prefix safe for URL concatenation. + + Flask defaults ``APPLICATION_ROOT`` to ``"/"``. Concatenating that (or any + value with a trailing slash) directly with a leading-slash path produces an + invalid protocol-relative URL such as ``//api/v1/_openapi``. Treat ``"/"`` as + an empty prefix and strip any trailing slash so the result is either empty or + a clean ``/prefix``. + """ + if not app_root: + return "" + return app_root.rstrip("/") + + def resolver(schema: Any) -> str: schema_cls = resolve_schema_cls(schema) name = schema_cls.__name__ @@ -86,8 +100,8 @@ class SupersetOpenApi(BaseApi): @staticmethod def _create_api_spec(version: str) -> APISpec: - app_root = current_app.config.get("APPLICATION_ROOT", "/") - default_server = {"url": request.host_url.rstrip("/") + app_root} + app_root = normalize_app_root(current_app.config.get("APPLICATION_ROOT", "/")) + default_server = {"url": request.host_url.rstrip("/") + (app_root or "/")} servers = current_app.config.get("FAB_OPENAPI_SERVERS", [default_server]) return APISpec( title=current_app.appbuilder.app_name, @@ -107,10 +121,11 @@ class SupersetSwaggerView(BaseView): @expose("/") @has_access def show(self, version: str) -> FlaskResponse: - app_root = current_app.config.get("APPLICATION_ROOT", "") + self.openapi_uri + app_root = normalize_app_root(current_app.config.get("APPLICATION_ROOT")) + openapi_uri = (app_root + self.openapi_uri).format(version) return self.render_template( current_app.config.get( "FAB_API_SWAGGER_TEMPLATE", "appbuilder/swagger/swagger.html" ), - openapi_uri=app_root.format(version), + openapi_uri=openapi_uri, ) diff --git a/tests/unit_tests/openapi/test_manager.py b/tests/unit_tests/openapi/test_manager.py index 43afd54e15f..f7fbb4d4631 100644 --- a/tests/unit_tests/openapi/test_manager.py +++ b/tests/unit_tests/openapi/test_manager.py @@ -14,9 +14,25 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import pytest from marshmallow import Schema -from superset.openapi.manager import resolver +from superset.openapi.manager import normalize_app_root, resolver + + +@pytest.mark.parametrize( + "app_root,expected", + [ + ("/", ""), + ("", ""), + (None, ""), + ("/prefix", "/prefix"), + ("/prefix/", "/prefix"), + ("/nested/prefix/", "/nested/prefix"), + ], +) +def test_normalize_app_root(app_root: str | None, expected: str) -> None: + assert normalize_app_root(app_root) == expected def test_resolver_strips_schema_suffix() -> None: