1- import datetime
2- import json
3- import os .path
1+ import re
42import time
5- from functools import wraps
63
74import flask
85import flask .cli
96import jinja2
107from flask import _app_ctx_stack , request
8+ from flask .cli import ScriptInfo
119from werkzeug .exceptions import BadRequest
12- from werkzeug .routing import parse_rule
10+ from werkzeug .middleware . dispatcher import DispatcherMiddleware
1311
14- from appmap ._implementation import generation , web_framework
12+ import appmap .wrapt as wrapt
1513from appmap ._implementation .detect_enabled import DetectEnabled
1614from appmap ._implementation .env import Env
1715from appmap ._implementation .event import HttpServerRequestEvent , HttpServerResponseEvent
16+ from appmap ._implementation .flask import remote_recording
1817from appmap ._implementation .recorder import Recorder
1918from appmap ._implementation .web_framework import AppmapMiddleware
2019from appmap ._implementation .web_framework import TemplateHandler as BaseTemplateHandler
@@ -45,62 +44,40 @@ def request_params(req):
4544 return values_dict (params .lists ())
4645
4746
47+ NP_PARAMS = re .compile (r"<Rule '(.*?)'" )
48+ NP_PARAM_DELIMS = str .maketrans ("<>" , "{}" )
49+
50+
4851class AppmapFlask (AppmapMiddleware ):
49- def __init__ (self , app = None ):
52+ """
53+ A Flask extension to add remote recording to an application.
54+ Should be loaded by default, but can also be added manually.
55+
56+ For example:
57+
58+ ```
59+ from appmap.flask import AppmapFlask
60+
61+ app = new Flask(__Name__)
62+ AppmapFlask().init_app(app)
63+ ```
64+ """
65+
66+ def __init__ (self ):
5067 super ().__init__ ()
51- self .app = app
52- if app is not None :
53- self .init_app (app )
5468
5569 def init_app (self , app ):
5670 if self .should_record ():
57- # it may record requests but not remote (APPMAP=false)
5871 self .recorder = Recorder .get_current ()
5972
6073 if DetectEnabled .should_enable ("remote" ):
61- app .add_url_rule (
62- self .record_url ,
63- "appmap_record_get" ,
64- view_func = self .record_get ,
65- methods = ["GET" ],
66- )
67- app .add_url_rule (
68- self .record_url ,
69- "appmap_record_post" ,
70- view_func = self .record_post ,
71- methods = ["POST" ],
72- )
73- app .add_url_rule (
74- self .record_url ,
75- "appmap_record_delete" ,
76- view_func = self .record_delete ,
77- methods = ["DELETE" ],
74+ app .wsgi_app = DispatcherMiddleware (
75+ app .wsgi_app , {"/_appmap" : remote_recording }
7876 )
7977
8078 app .before_request (self .before_request )
8179 app .after_request (self .after_request )
8280
83- def record_get (self ):
84- if not self .should_record ():
85- return "Appmap is disabled." , 404
86-
87- return {"enabled" : self .recorder .get_enabled ()}
88-
89- def record_post (self ):
90- if self .recorder .get_enabled ():
91- return "Recording is already in progress" , 409
92-
93- self .recorder .start_recording ()
94- return "" , 200
95-
96- def record_delete (self ):
97- if not self .recorder .get_enabled ():
98- return "No recording is in progress" , 404
99-
100- self .recorder .stop_recording ()
101-
102- return json .loads (generation .dump (self .recorder ))
103-
10481 def before_request (self ):
10582 if not self .should_record ():
10683 return
@@ -112,16 +89,17 @@ def before_request(self):
11289 def before_request_main (self , rec , request ):
11390 Metadata .add_framework ("flask" , flask .__version__ )
11491 np = None
115- # See
116- # https://github.com/pallets/werkzeug/blob/2.0.0/src/werkzeug/routing.py#L213
117- # for a description of parse_rule.
11892 if request .url_rule :
119- np = "" .join (
120- [
121- f"{{{ p } }}" if c else p
122- for c , _ , p in parse_rule (request .url_rule .rule )
123- ]
124- )
93+ # Transform request.url to the expected normalized-path form. For example,
94+ # "/post/<username>/<post_id>/summary" becomes "/post/{username}/{post_id}/summary".
95+ # Notes:
96+ # * the value of `repr` of this rule begins with "<Rule '/post/<username>/<post_id>/summary'"
97+ # * the variable names in a rule can only contain alphanumerics:
98+ # * flask 1: https://github.com/pallets/werkzeug/blob/1dde4b1790f9c46b7122bb8225e6b48a5b22a615/src/werkzeug/routing.py#L143
99+ # * flask 2: https://github.com/pallets/werkzeug/blob/99f328cf2721e913bd8a3128a9cdd95ca97c334c/src/werkzeug/routing/rules.py#L56
100+ r = repr (request .url_rule )
101+ np = NP_PARAMS .findall (r )[0 ].translate (NP_PARAM_DELIMS )
102+
125103 call_event = HttpServerRequestEvent (
126104 request_method = request .method ,
127105 path_info = request .path ,
@@ -174,18 +152,18 @@ class TemplateHandler(BaseTemplateHandler):
174152 pass
175153
176154
177- def wrap_cli_fn (fn ):
178- @wraps (fn )
179- def install_middleware (* args , ** kwargs ):
180- app = fn (* args , ** kwargs )
181- if app :
182- appmap_flask = AppmapFlask ()
183- appmap_flask .init_app (app )
184- return app
155+ def install_extension (wrapped , _ , args , kwargs ):
156+ app = wrapped (* args , ** kwargs )
157+ if app :
158+ AppmapFlask ().init_app (app )
185159
186- return install_middleware
160+ return app
187161
188162
189163if Env .current .enabled :
190- flask .cli .call_factory = wrap_cli_fn (flask .cli .call_factory )
191- flask .cli .locate_app = wrap_cli_fn (flask .cli .locate_app )
164+ # ScriptInfo.load_app is the function that's used by the Flask cli to load an app, no matter how
165+ # the app's module is specified (e.g. with the FLASK_APP env var, the `--app` flag, etc). Hook
166+ # it so it installs our extension on the app.
167+ ScriptInfo .load_app = wrapt .wrap_function_wrapper (
168+ "flask.cli" , "ScriptInfo.load_app" , install_extension
169+ )
0 commit comments