diff --git a/elasticapm/contrib/starlette/__init__.py b/elasticapm/contrib/starlette/__init__.py index 3dfb225c9..1160b0de5 100644 --- a/elasticapm/contrib/starlette/__init__.py +++ b/elasticapm/contrib/starlette/__init__.py @@ -53,6 +53,47 @@ logger = get_logger("elasticapm.errors.client") +try: + # FastAPI >= 0.137.2 exposes a public helper that flattens routes added via + # include_router() (nested under _IncludedRouter in 0.137) into matchable + # RouteContext objects. Older versions don't have it; see _flatten_routes(). + from fastapi.routing import iter_route_contexts +except ImportError: + iter_route_contexts = None + + +def _flatten_routes(routes): + """ + Yield routes in the shape expected by Starlette's route matching. + + FastAPI 0.137 changed include_router() to keep nested routers as + _IncludedRouter entries. Those entries do not expose ``path`` directly, so + they need to be expanded into effective route contexts before matching. + + FastAPI >= 0.137.2 provides iter_route_contexts() for that expansion. For + 0.137.0 and 0.137.1, use _IncludedRouter.effective_route_contexts(). Older + FastAPI versions and plain Starlette already expose matchable route objects. + """ + # FastAPI 0.137+ may include _IncludedRouter placeholders. + if any(hasattr(route, "effective_route_contexts") for route in routes): + if iter_route_contexts is not None: + yield from iter_route_contexts(routes) + return + for route in routes: + if hasattr(route, "effective_route_contexts"): + yield from route.effective_route_contexts() + else: + yield route + return + + # Older FastAPI and plain Starlette routes can be matched directly. + for route in routes: + yield route + + +def _is_mount_route(route): + return isinstance(route, Mount) or isinstance(getattr(route, "route", None), Mount) + def make_apm_client(config: Optional[Dict] = None, client_cls=Client, **defaults) -> Client: """Builds ElasticAPM client. @@ -268,12 +309,12 @@ def get_route_name(self, request: Request) -> str: return route_name def _get_route_name(self, scope, routes, route_name=None): - for route in routes: + for route in _flatten_routes(routes): match, child_scope = route.matches(scope) if match == Match.FULL: route_name = route.path child_scope = {**scope, **child_scope} - if isinstance(route, Mount) and route.routes: + if _is_mount_route(route) and route.routes: child_route_name = self._get_route_name(child_scope, route.routes, route_name) if child_route_name is None: route_name = None diff --git a/tests/contrib/asyncio/fastapi_tests.py b/tests/contrib/asyncio/fastapi_tests.py new file mode 100644 index 000000000..cebfa669e --- /dev/null +++ b/tests/contrib/asyncio/fastapi_tests.py @@ -0,0 +1,128 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from tests.fixtures import TempStoreClient + +import pytest # isort:skip + +fastapi = pytest.importorskip("fastapi") # isort:skip + +from fastapi import APIRouter, FastAPI +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +import elasticapm +from elasticapm.conf import constants +from elasticapm.contrib.starlette import ElasticAPM, make_apm_client + +pytestmark = [pytest.mark.starlette] + + +@pytest.fixture +def fastapi_app(elasticapm_client): + router = APIRouter(prefix="/api") + + @router.get("/items/{item_id}") + async def get_item(item_id: int): + return {"item_id": item_id} + + app = FastAPI() + app.include_router(router) + app.add_middleware(ElasticAPM, client=elasticapm_client) + + yield app + + elasticapm.uninstrument() + + +def test_included_router_request_succeeds(fastapi_app, elasticapm_client): + client = TestClient(fastapi_app) + + response = client.get("/api/items/42") + + assert response.status_code == 200 + assert response.json() == {"item_id": 42} + assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + + +def test_included_router_transaction_name(fastapi_app, elasticapm_client): + client = TestClient(fastapi_app) + + response = client.get("/api/items/42") + + assert response.status_code == 200 + + transaction = elasticapm_client.events[constants.TRANSACTION][0] + assert transaction["name"] == "GET /api/items/{item_id}" + + +def test_included_router_with_mounted_app_transaction_name(elasticapm_client): + router = APIRouter(prefix="/api") + + @router.get("/items/{item_id}") + async def get_item(item_id: int): + return {"item_id": item_id} + + async def hi(request): + return PlainTextResponse("sub") + + sub = Starlette(routes=[Route("/hi", hi)]) + app = FastAPI() + app.include_router(router) + app.mount("/sub", sub) + app.add_middleware(ElasticAPM, client=elasticapm_client) + client = TestClient(app) + + try: + response = client.get("/sub/hi") + + assert response.status_code == 200 + assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + transaction = elasticapm_client.events[constants.TRANSACTION][0] + assert transaction["name"] == "GET /sub/hi" + finally: + elasticapm.uninstrument() + + +def test_included_router_options_partial_match(fastapi_app, elasticapm_client): + client = TestClient(fastapi_app) + + response = client.options("/api/items/42") + + assert response.status_code == 405 + assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + + +def test_make_client_with_fastapi_framework(): + c = make_apm_client(config={"SERVICE_NAME": "foo"}, client_cls=TempStoreClient, framework_name="fastapi") + c.close() + assert c.config.service_name == "foo" diff --git a/tests/requirements/reqs-starlette-newest.txt b/tests/requirements/reqs-starlette-newest.txt index b81367f0a..cbf2e04d5 100644 --- a/tests/requirements/reqs-starlette-newest.txt +++ b/tests/requirements/reqs-starlette-newest.txt @@ -1,4 +1,5 @@ starlette>0.15,<1 +fastapi<1 aiofiles httpx flask