Skip to content

Commit fa786ce

Browse files
committed
feat: add FastAPI integration
1 parent 59b355c commit fa786ce

19 files changed

+500
-151
lines changed

_appmap/event.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
from .recorder import Recorder
1111
from .utils import (
1212
FnType,
13+
FqFnName,
1314
appmap_tls,
1415
compact_dict,
1516
fqname,
1617
get_function_location,
17-
split_function_name,
1818
)
1919

2020
logger = Env.current.getLogger(__name__)
@@ -173,7 +173,7 @@ def to_dict(self, value):
173173

174174

175175
class CallEvent(Event):
176-
__slots__ = ["_fn", "static", "receiver", "parameters", "labels"]
176+
__slots__ = ["_fn", "_fqfn", "static", "receiver", "parameters", "labels"]
177177

178178
@staticmethod
179179
def make(fn, fntype):
@@ -209,10 +209,9 @@ def make_params(filterable):
209209
# going to log a message about a mismatch.
210210
wrapped_sig = inspect.signature(fn, follow_wrapped=True)
211211
if sig != wrapped_sig:
212-
logger.debug(
213-
"signature of wrapper %s.%s doesn't match wrapped",
214-
*split_function_name(fn)
215-
)
212+
logger.debug("signature of wrapper %r doesn't match wrapped", fn)
213+
logger.debug("sig: %r", sig)
214+
logger.debug("wrapped_sig: %r", wrapped_sig)
216215

217216
return [Param(p) for p in sig.parameters.values()]
218217

@@ -270,17 +269,17 @@ def set_params(params, instance, args, kwargs):
270269
@property
271270
@lru_cache(maxsize=None)
272271
def function_name(self):
273-
return split_function_name(self._fn)
272+
return self._fqfn.fqfn
274273

275274
@property
276275
@lru_cache(maxsize=None)
277276
def defined_class(self):
278-
return self.function_name[0]
277+
return self._fqfn.fqclass
279278

280279
@property
281280
@lru_cache(maxsize=None)
282281
def method_id(self):
283-
return self.function_name[1]
282+
return self._fqfn.fqfn[1]
284283

285284
@property
286285
@lru_cache(maxsize=None)
@@ -308,6 +307,7 @@ def comment(self):
308307
def __init__(self, fn, fntype, parameters, labels):
309308
super().__init__("call")
310309
self._fn = fn
310+
self._fqfn = FqFnName(fn)
311311
self.static = fntype in FnType.STATIC | FnType.CLASS | FnType.MODULE
312312
self.receiver = None
313313
if fntype in FnType.CLASS | FnType.INSTANCE:
@@ -351,7 +351,15 @@ class MessageEvent(Event): # pylint: disable=too-few-public-methods
351351
def __init__(self, message_parameters):
352352
super().__init__("call")
353353
self.message = []
354-
for name, value in message_parameters.items():
354+
self.message_parameters = message_parameters
355+
356+
@property
357+
def message_parameters(self):
358+
return self.message
359+
360+
@message_parameters.setter
361+
def message_parameters(self, params):
362+
for name, value in params.items():
355363
message_object = describe_value(name, value)
356364
self.message.append(message_object)
357365

@@ -386,6 +394,7 @@ def __init__(self, request_method, url, message_parameters, headers=None):
386394

387395

388396
# pylint: disable=too-few-public-methods
397+
_NORMALIZED_PATH_INFO_ATTR = "normalized_path_info"
389398
class HttpServerRequestEvent(MessageEvent):
390399
"""A call AppMap event representing an HTTP server request."""
391400

@@ -406,7 +415,7 @@ def __init__(
406415
"request_method": request_method,
407416
"protocol": protocol,
408417
"path_info": path_info,
409-
"normalized_path_info": normalized_path_info,
418+
_NORMALIZED_PATH_INFO_ATTR: normalized_path_info,
410419
}
411420

412421
if headers is not None:
@@ -420,6 +429,14 @@ def __init__(
420429

421430
self.http_server_request = compact_dict(request)
422431

432+
@property
433+
def normalized_path_info(self):
434+
return self.http_server_request.get(_NORMALIZED_PATH_INFO_ATTR, None)
435+
436+
@normalized_path_info.setter
437+
def normalized_path_info(self, npi):
438+
self.http_server_request[_NORMALIZED_PATH_INFO_ATTR] = npi
439+
423440

424441
class ReturnEvent(Event):
425442
__slots__ = ["parent_id", "elapsed"]

_appmap/importer.py

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,56 +10,49 @@
1010
from _appmap import wrapt
1111

1212
from .env import Env
13-
from .utils import FnType
13+
from .utils import FnType, Scope
1414

1515
logger = Env.current.getLogger(__name__)
1616

1717

18-
Filterable = namedtuple("Filterable", "fqname obj")
18+
Filterable = namedtuple("Filterable", "scope fqname obj")
1919

2020

2121
class FilterableMod(Filterable):
2222
__slots__ = ()
2323

2424
def __new__(cls, mod):
2525
fqname = mod.__name__
26-
return super(FilterableMod, cls).__new__(cls, fqname, mod)
27-
28-
def classify_fn(self, _):
29-
return FnType.MODULE
26+
return super(FilterableMod, cls).__new__(cls, Scope.MODULE, fqname, mod)
3027

3128

3229
class FilterableCls(Filterable):
3330
__slots__ = ()
3431

3532
def __new__(cls, clazz):
3633
fqname = "%s.%s" % (clazz.__module__, clazz.__qualname__)
37-
return super(FilterableCls, cls).__new__(cls, fqname, clazz)
38-
39-
def classify_fn(self, static_fn):
40-
return FnType.classify(static_fn)
34+
return super(FilterableCls, cls).__new__(cls, Scope.CLASS, fqname, clazz)
4135

4236

4337
class FilterableFn(
4438
namedtuple(
4539
"FilterableFn",
46-
Filterable._fields
47-
+ (
48-
"scope",
49-
"static_fn",
50-
),
40+
Filterable._fields + ("static_fn",),
5141
)
5242
):
5343
__slots__ = ()
5444

5545
def __new__(cls, scope, fn, static_fn):
5646
fqname = "%s.%s" % (scope.fqname, fn.__name__)
57-
self = super(FilterableFn, cls).__new__(cls, fqname, fn, scope, static_fn)
47+
self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn)
5848
return self
5949

6050
@property
6151
def fntype(self):
62-
return self.scope.classify_fn(self.static_fn)
52+
if self.scope == Scope.MODULE:
53+
return FnType.MODULE
54+
else:
55+
return FnType.classify(self.static_fn)
6356

6457

6558
class Filter(ABC): # pylint: disable=too-few-public-methods
@@ -161,6 +154,17 @@ def initialize(cls):
161154
def use_filter(cls, filter_class):
162155
cls.filter_stack.append(filter_class)
163156

157+
@classmethod
158+
def instrument_function(cls, fn_name, filterableFn: FilterableFn, selected_functions=None):
159+
# Only instrument the function if it was specifically called out for the package
160+
# (e.g. because it should be labeled), or it's included by the filters
161+
matched = cls.filter_chain.filter(filterableFn)
162+
selected = selected_functions and fn_name in selected_functions
163+
if selected or matched:
164+
return cls.filter_chain.wrap(filterableFn)
165+
166+
return filterableFn.obj
167+
164168
@classmethod
165169
def do_import(cls, *args, **kwargs):
166170
mod = args[0]
@@ -177,15 +181,10 @@ def instrument_functions(filterable, selected_functions=None):
177181
logger.trace(" functions %s", functions)
178182

179183
for fn_name, static_fn, fn in functions:
180-
# Only instrument the function if it was specifically called out for the package
181-
# (e.g. because it should be labeled), or it's included by the filters
182184
filterableFn = FilterableFn(filterable, fn, static_fn)
183-
matched = cls.filter_chain.filter(filterableFn)
184-
selected = selected_functions and fn_name in selected_functions
185-
if selected or matched:
186-
new_fn = cls.filter_chain.wrap(filterableFn)
187-
if fn != new_fn:
188-
wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
185+
new_fn = cls.instrument_function(fn_name, filterableFn, selected_functions)
186+
if new_fn != fn:
187+
wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
189188

190189
# Import Config here, to avoid circular top-level imports.
191190
from .configuration import Config # pylint: disable=import-outside-toplevel
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: FastAPITest
2+
packages:
3+
- path: fastapiapp

_appmap/test/data/fastapi/fastapiapp/__init__.py

Whitespace-only changes.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
Rudimentary FastAPI application for testing.
3+
4+
NB: This should not explicitly reference the `appmap` module in any way. Doing so invalidates
5+
testing of record-by-default.
6+
"""
7+
# pylint: disable=missing-function-docstring
8+
9+
from typing import List
10+
11+
from fastapi import FastAPI, Query, Request, Response
12+
13+
app = FastAPI()
14+
15+
16+
@app.get("/")
17+
def hello_world():
18+
return {"Hello": "World!"}
19+
20+
21+
@app.post("/echo")
22+
async def echo(request: Request):
23+
body = await request.body()
24+
return Response(content=body, media_type="application/json")
25+
26+
27+
@app.get("/test")
28+
async def get_test(my_params: List[str] = Query(None)):
29+
response = Response(content="test", media_type="text/html; charset=utf-8")
30+
response.headers["ETag"] = "W/01"
31+
return response
32+
33+
34+
@app.post("/test")
35+
async def post_test(request: Request):
36+
await request.json()
37+
response = Response(content='{"test":true}', media_type="application/json")
38+
response.headers["ETag"] = "W/01"
39+
return response
40+
41+
42+
@app.get("/user/{username}")
43+
def get_user_profile(username):
44+
# show the user profile for that user
45+
return {"user": username}
46+
47+
48+
@app.get("/post/{post_id:int}")
49+
def get_post(post_id):
50+
# show the post with the given id, the id is an integer
51+
return {"post": post_id}
52+
53+
54+
@app.get("/post/{username}/{post_id:int}/summary")
55+
def get_user_post(username, post_id):
56+
# Show the summary of a user's post
57+
return {"user": username, "post": post_id}
58+
59+
60+
@app.get("/{org:int}/posts/{username}")
61+
def get_org_user_posts(org, username):
62+
return {"org": org, "username": username}
63+
64+
65+
@app.route("/exception")
66+
def raise_exception():
67+
raise Exception("An exception")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import appmap
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import pytest
2+
from fastapi.testclient import TestClient
3+
from fastapiapp import app
4+
5+
6+
@pytest.fixture
7+
def client():
8+
yield TestClient(app)
9+
10+
11+
def test_request(client):
12+
response = client.get("/")
13+
14+
assert response.status_code == 200

_appmap/test/helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,14 @@ class DictIncluding(dict):
1515

1616
def __eq__(self, other):
1717
return other.items() >= self.items()
18+
19+
20+
class HeadersIncluding(dict):
21+
"""Like DictIncluding, but key comparison is case-insensitive."""
22+
23+
def __eq__(self, other):
24+
for k, v in self.items():
25+
v1 = other.get(k, other.get(k.lower(), None))
26+
if v1 is None:
27+
return False
28+
return True

_appmap/test/test_fastapi.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import importlib.metadata
2+
3+
import pytest
4+
from fastapi.testclient import TestClient
5+
6+
import appmap
7+
from _appmap.env import Env
8+
from _appmap.metadata import Metadata
9+
10+
# pylint: disable=unused-import
11+
from .web_framework import TestRequestCapture
12+
13+
# pylint: enable=unused-import
14+
15+
pytestmark = pytest.mark.web
16+
17+
@pytest.fixture(name="app")
18+
def fastapi_app(data_dir, monkeypatch):
19+
monkeypatch.syspath_prepend(data_dir / "fastapi")
20+
21+
Env.current.set("APPMAP_CONFIG", data_dir / "fastapi" / "appmap.yml")
22+
23+
from fastapiapp import main # pyright: ignore[reportMissingImports]
24+
25+
importlib.reload(main)
26+
27+
# Add the FastAPI middleware to the app. This now happens automatically when a FastAPI app is
28+
# started from the command line, but must be done manually otherwise.
29+
main.app.add_middleware(appmap.fastapi.Middleware)
30+
31+
return main.app
32+
33+
34+
@pytest.fixture(name="client")
35+
def fastapi_client(app):
36+
yield TestClient(app)
37+
38+
39+
@pytest.mark.appmap_enabled(env={"APPMAP_RECORD_REQUESTS": "false"})
40+
def test_framework_metadata(client, events): # pylint: disable=unused-argument
41+
client.get("/")
42+
assert Metadata()["frameworks"] == [
43+
{"name": "FastAPI", "version": importlib.metadata.version("fastapi")}
44+
]

0 commit comments

Comments
 (0)