From 4356b6cc33cb64754a56b73e208704f715ac6b20 Mon Sep 17 00:00:00 2001 From: BarnabasG Date: Sun, 12 Apr 2026 22:44:13 +0100 Subject: [PATCH] 1.3.4 Enhance FastAPI endpoint collection to include mounted sub-applications and WSGI middleware routes --- pyproject.toml | 2 +- src/pytest_api_cov/frameworks.py | 48 ++++++-- .../test_fastapi_mount_integration.py | 110 ++++++++++++++++++ uv.lock | 2 +- 4 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 tests/integration/test_fastapi_mount_integration.py diff --git a/pyproject.toml b/pyproject.toml index 4b7903c..4e08255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytest-api-cov" -version = "1.3.3" +version = "1.3.4" description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks" readme = "README.md" authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }] diff --git a/src/pytest_api_cov/frameworks.py b/src/pytest_api_cov/frameworks.py index aabb307..8e7658a 100644 --- a/src/pytest_api_cov/frameworks.py +++ b/src/pytest_api_cov/frameworks.py @@ -67,18 +67,34 @@ class FastAPIAdapter(BaseAdapter): def get_endpoints(self) -> List[str]: """Return list of 'METHOD /path' strings.""" - from fastapi.routing import APIRoute - - endpoints = [ - f"{method} {route.path}" - for route in self.app.routes - if isinstance(route, APIRoute) - for method in route.methods - if method not in ("HEAD", "OPTIONS") - ] - + endpoints: List[str] = [] + self._collect_routes(self.app.routes, "", endpoints) return sorted(endpoints) + def _collect_routes(self, routes: List[Any], prefix: str, endpoints: List[str]) -> None: + """Recursively collect endpoints from routes, including mounted sub-apps.""" + from fastapi.routing import APIRoute + from starlette.routing import Mount + + for route in routes: + if isinstance(route, APIRoute): + endpoints.extend( + f"{method} {prefix}{route.path}" for method in route.methods if method not in ("HEAD", "OPTIONS") + ) + elif isinstance(route, Mount): + mount_prefix = prefix + route.path + # Sub-app with its own routes (FastAPI/Starlette router) + if hasattr(route, "routes") and route.routes: + self._collect_routes(route.routes, mount_prefix, endpoints) + # WSGI middleware wrapping a supported framework + elif hasattr(route, "app"): + inner = _unwrap_wsgi_app(route.app) + if inner is not None: + sub_endpoints = get_framework_adapter(inner).get_endpoints() + for ep in sub_endpoints: + method, path = ep.split(" ", 1) + endpoints.append(f"{method} {mount_prefix}{path}") + def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any: """Return a patched test client that records calls.""" from starlette.testclient import TestClient @@ -149,6 +165,18 @@ def request(self, **request: Any) -> Any: return TrackingDjangoClient() +def _unwrap_wsgi_app(app: Any) -> Any: + """Extract the inner WSGI app from middleware wrappers, if supported.""" + from .plugin import is_supported_framework + + type_name = type(app).__name__ + if type_name in ("WSGIMiddleware", "WSGIResponder"): + inner = getattr(app, "app", None) + if inner is not None and is_supported_framework(inner): + return inner + return None + + def get_framework_adapter(app: Any) -> BaseAdapter: """Detect the framework and return the appropriate adapter.""" app_type = type(app).__name__ diff --git a/tests/integration/test_fastapi_mount_integration.py b/tests/integration/test_fastapi_mount_integration.py new file mode 100644 index 0000000..a03472b --- /dev/null +++ b/tests/integration/test_fastapi_mount_integration.py @@ -0,0 +1,110 @@ +"""Integration tests for FastAPI mounted sub-app route discovery.""" + +pytest_plugins = ["pytester"] + + +def test_fastapi_with_mounted_wsgi_flask_app(pytester): + """Test that routes from a Flask app mounted via WSGIMiddleware are discovered.""" + pytester.makepyfile( + """ + from fastapi import FastAPI + from fastapi.middleware.wsgi import WSGIMiddleware + from flask import Flask + import pytest + + flask_app = Flask(__name__) + + @flask_app.route("/hello") + def flask_hello(): + return "Hello from Flask" + + fastapi_app = FastAPI() + + @fastapi_app.get("/") + def fastapi_root(): + return {"message": "Hello from FastAPI"} + + fastapi_app.mount("/flask", WSGIMiddleware(flask_app)) + + @pytest.fixture + def app(): + return fastapi_app + + def test_root(coverage_client): + response = coverage_client.get("/") + assert response.status_code == 200 + + def test_flask_hello(coverage_client): + response = coverage_client.get("/flask/hello") + assert response.status_code == 200 + """ + ) + + result = pytester.runpytest( + "--api-cov-report", + "--api-cov-show-covered-endpoints", + "--api-cov-force-sugar-disabled", + ) + + assert result.ret == 0 + output = result.stdout.str() + assert "API Coverage Report" in output + # FastAPI route should be discovered + assert "GET /" in output + # Flask mounted route should also be discovered with prefix + assert "GET /flask/hello" in output + + +def test_fastapi_with_mounted_sub_app(pytester): + """Test that routes from a mounted FastAPI sub-app are discovered.""" + pytester.makepyfile( + """ + from fastapi import FastAPI + import pytest + + sub_app = FastAPI() + + @sub_app.get("/users") + def list_users(): + return [{"id": 1}] + + @sub_app.get("/users/{user_id}") + def get_user(user_id: int): + return {"id": user_id} + + main_app = FastAPI() + + @main_app.get("/") + def root(): + return {"message": "Hello"} + + main_app.mount("/api/v2", sub_app) + + @pytest.fixture + def app(): + return main_app + + def test_root(coverage_client): + response = coverage_client.get("/") + assert response.status_code == 200 + + def test_list_users(coverage_client): + response = coverage_client.get("/api/v2/users") + assert response.status_code == 200 + """ + ) + + result = pytester.runpytest( + "--api-cov-report", + "--api-cov-show-covered-endpoints", + "--api-cov-force-sugar-disabled", + ) + + assert result.ret == 0 + output = result.stdout.str() + assert "API Coverage Report" in output + # Main app route + assert "GET /" in output + # Sub-app routes should be discovered with prefix + assert "GET /api/v2/users" in output + assert "GET /api/v2/users/{user_id}" in output diff --git a/uv.lock b/uv.lock index 319bf71..c5f8aed 100644 --- a/uv.lock +++ b/uv.lock @@ -687,7 +687,7 @@ wheels = [ [[package]] name = "pytest-api-cov" -version = "1.3.3" +version = "1.3.4" source = { editable = "." } dependencies = [ { name = "pydantic" },