Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }]
Expand Down
48 changes: 38 additions & 10 deletions src/pytest_api_cov/frameworks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__
Expand Down
110 changes: 110 additions & 0 deletions tests/integration/test_fastapi_mount_integration.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading