diff --git a/superset/openapi/manager.py b/superset/openapi/manager.py index 7bc6a1a04db..a905eae6278 100644 --- a/superset/openapi/manager.py +++ b/superset/openapi/manager.py @@ -48,6 +48,19 @@ def normalize_app_root(app_root: str | None) -> str: return app_root.rstrip("/") +def resolve_url_prefix() -> str: + """Resolve the URL prefix used to build OpenAPI/Swagger links. + + Prefer the request's script root so reverse-proxy prefixes exposed via + ``X-Forwarded-Prefix`` / ``SCRIPT_NAME`` keep working (this is what FAB's + stock Swagger view does), and fall back to a normalized ``APPLICATION_ROOT`` + when no script root is present. + """ + return request.script_root or normalize_app_root( + current_app.config.get("APPLICATION_ROOT") + ) + + def resolver(schema: Any) -> str: schema_cls = resolve_schema_cls(schema) name = schema_cls.__name__ @@ -100,8 +113,8 @@ class SupersetOpenApi(BaseApi): @staticmethod def _create_api_spec(version: str) -> APISpec: - app_root = normalize_app_root(current_app.config.get("APPLICATION_ROOT", "/")) - default_server = {"url": request.host_url.rstrip("/") + (app_root or "/")} + prefix = resolve_url_prefix() + default_server = {"url": request.host_url.rstrip("/") + (prefix or "/")} servers = current_app.config.get("FAB_OPENAPI_SERVERS", [default_server]) return APISpec( title=current_app.appbuilder.app_name, @@ -121,8 +134,7 @@ class SupersetSwaggerView(BaseView): @expose("/") @has_access def show(self, version: str) -> FlaskResponse: - app_root = normalize_app_root(current_app.config.get("APPLICATION_ROOT")) - openapi_uri = (app_root + self.openapi_uri).format(version) + openapi_uri = (resolve_url_prefix() + self.openapi_uri).format(version) return self.render_template( current_app.config.get( "FAB_API_SWAGGER_TEMPLATE", "appbuilder/swagger/swagger.html" diff --git a/tests/unit_tests/openapi/test_manager.py b/tests/unit_tests/openapi/test_manager.py index f7fbb4d4631..ba4c2cd3497 100644 --- a/tests/unit_tests/openapi/test_manager.py +++ b/tests/unit_tests/openapi/test_manager.py @@ -15,9 +15,14 @@ # specific language governing permissions and limitations # under the License. import pytest +from flask import Flask from marshmallow import Schema -from superset.openapi.manager import normalize_app_root, resolver +from superset.openapi.manager import ( + normalize_app_root, + resolve_url_prefix, + resolver, +) @pytest.mark.parametrize( @@ -35,6 +40,29 @@ def test_normalize_app_root(app_root: str | None, expected: str) -> None: assert normalize_app_root(app_root) == expected +def test_resolve_url_prefix_prefers_script_root() -> None: + # A reverse-proxy prefix (X-Forwarded-Prefix / SCRIPT_NAME) must win over + # APPLICATION_ROOT so FAB's script_root behavior isn't dropped. + app = Flask(__name__) + app.config["APPLICATION_ROOT"] = "/config-root" + with app.test_request_context(environ_overrides={"SCRIPT_NAME": "/proxy-root"}): + assert resolve_url_prefix() == "/proxy-root" + + +def test_resolve_url_prefix_falls_back_to_app_root() -> None: + app = Flask(__name__) + app.config["APPLICATION_ROOT"] = "/config-root" + with app.test_request_context(): + assert resolve_url_prefix() == "/config-root" + + +def test_resolve_url_prefix_empty_when_neither_set() -> None: + app = Flask(__name__) + app.config["APPLICATION_ROOT"] = "/" + with app.test_request_context(): + assert resolve_url_prefix() == "" + + def test_resolver_strips_schema_suffix() -> None: class FooSchema(Schema): pass