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
45 changes: 43 additions & 2 deletions elasticapm/contrib/starlette/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions tests/contrib/asyncio/fastapi_tests.py
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions tests/requirements/reqs-starlette-newest.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
starlette>0.15,<1
fastapi<1
aiofiles
httpx
flask
Expand Down
Loading