Skip to content

Commit 4ddf40e

Browse files
committed
Merge dev into main
2 parents 700e637 + cf0c686 commit 4ddf40e

34 files changed

Lines changed: 976 additions & 933 deletions

.github/workflows/ci-pipeline__dev.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,20 @@ jobs:
3939
needs:
4040
- run-unit-tests
4141

42+
publish-to-pypi:
43+
permissions:
44+
id-token: write
45+
name: "Publish to PYPI"
46+
runs-on: ubuntu-latest
47+
48+
steps:
49+
- uses: actions/checkout@v4
50+
51+
- name: Git Update Current Branch
52+
uses: owasp-sbot/OSBot-GitHub-Actions/.github/actions/git__update_branch@dev
53+
54+
- name: publish-to-pypi
55+
uses: owasp-sbot/OSBot-GitHub-Actions/.github/actions/pypi__publish@dev
56+
needs:
57+
- increment-tag
58+

osbot_fast_api/api/Fast_API.py

Lines changed: 96 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,60 @@
1+
import traceback
12
import types
23

3-
from fastapi import FastAPI
4-
from starlette.middleware.wsgi import WSGIMiddleware # todo replace this with a2wsgi
5-
from osbot_fast_api.utils.Version import Version
6-
from osbot_utils.base_classes.Type_Safe import Type_Safe
7-
from starlette.middleware.cors import CORSMiddleware
8-
from starlette.responses import RedirectResponse
9-
from starlette.staticfiles import StaticFiles
10-
from osbot_utils.utils.Misc import list_set
11-
from osbot_utils.decorators.lists.index_by import index_by
12-
from osbot_utils.decorators.methods.cache_on_self import cache_on_self
13-
from starlette.testclient import TestClient
14-
from osbot_fast_api.api.routes.Routes_Config import Routes_Config
15-
from osbot_fast_api.utils.http_shell.Http_Shell__Server import Model__Shell_Command, Http_Shell__Server
16-
from osbot_fast_api.utils.Fast_API_Utils import Fast_API_Utils
17-
from osbot_fast_api.utils._extra_osbot_utils import list_minus_list
18-
19-
DEFAULT_ROUTES_PATHS = ['/', '/config/status', '/config/version']
4+
from fastapi import FastAPI, Request, HTTPException
5+
from fastapi.exceptions import RequestValidationError
6+
from starlette.middleware.wsgi import WSGIMiddleware # todo replace this with a2wsgi
7+
from osbot_fast_api.api.Fast_API__Http_Events import Fast_API__Http_Events
8+
from osbot_fast_api.api.middlewares.Middleware__Http_Request import Middleware__Http_Request
9+
from osbot_fast_api.api.middlewares.Middleware__Http_Request__Duration import Middleware__Http_Request__Duration
10+
from osbot_fast_api.api.middlewares.Middleware__Http_Request__Trace_Calls import Middleware__Http_Request__Trace_Calls
11+
from osbot_fast_api.utils.Version import Version
12+
from osbot_utils.base_classes.Type_Safe import Type_Safe
13+
from starlette.middleware.cors import CORSMiddleware
14+
from starlette.responses import RedirectResponse, JSONResponse
15+
from starlette.staticfiles import StaticFiles
16+
from osbot_utils.utils.Lists import list_index_by
17+
from osbot_utils.utils.Misc import list_set
18+
from osbot_utils.decorators.lists.index_by import index_by
19+
from osbot_utils.decorators.methods.cache_on_self import cache_on_self
20+
from starlette.testclient import TestClient
21+
from osbot_fast_api.api.routes.Routes_Config import Routes_Config
22+
from osbot_fast_api.utils.http_shell.Http_Shell__Server import Model__Shell_Command, Http_Shell__Server
23+
from osbot_fast_api.utils.Fast_API_Utils import Fast_API_Utils
24+
25+
DEFAULT_ROUTES_PATHS = ['/', '/config/status', '/config/version']
26+
DEFAULT__NAME__FAST_API = 'Fast_API'
2027

2128
class Fast_API(Type_Safe):
22-
enable_cors : bool
29+
base_path : str = '/'
30+
enable_cors : bool = False
31+
default_routes : bool = True
32+
name : str = None
33+
http_events : Fast_API__Http_Events
34+
35+
def __init__(self, **kwargs):
36+
super().__init__(**kwargs)
37+
self.name = self.__class__.__name__
38+
self.http_events.fast_api_name = self.name
39+
40+
def add_global_exception_handlers(self): # todo: move to Fast_API
41+
app = self.app()
42+
@app.exception_handler(Exception)
43+
async def global_exception_handler(request: Request, exc: Exception):
44+
stack_trace = traceback.format_exc()
45+
content = { "detail" : "An unexpected error occurred." ,
46+
"error" : str(exc) ,
47+
"stack_trace" : stack_trace }
48+
return JSONResponse( status_code=500, content=content)
49+
50+
@app.exception_handler(HTTPException)
51+
async def http_exception_handler(request: Request, exc: HTTPException):
52+
return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail})
53+
54+
@app.exception_handler(RequestValidationError)
55+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
56+
return JSONResponse( status_code=400, content={"detail": exc.errors()} )
57+
2358

2459
def add_flask_app(self, path, flask_app):
2560
self.app().mount(path, WSGIMiddleware(flask_app))
@@ -42,12 +77,12 @@ def add_route_post(self, function):
4277
return self.add_route(function=function, methods=['POST'])
4378

4479
def add_routes(self, class_routes):
45-
class_routes(self.app())
80+
class_routes(app=self.app()).setup()
4681
return self
4782

4883
@cache_on_self
49-
def app(self):
50-
return FastAPI()
84+
def app(self, **kwargs):
85+
return FastAPI(**kwargs)
5186

5287
def app_router(self):
5388
return self.app().router
@@ -61,35 +96,54 @@ def fast_api_utils(self):
6196
def path_static_folder(self): # override this to add support for serving static files from this directory
6297
return None
6398

99+
def mount(self, parent_app):
100+
parent_app.mount(self.base_path, self.app())
101+
64102
def setup(self):
65-
self.setup_middlewares () # overwrite to add middlewares
66-
self.setup_default_middlewares()
67-
self.setup_default_routes ()
68-
self.setup_static_routes ()
69-
self.setup_routes () # overwrite to add routes
103+
self.add_global_exception_handlers()
104+
self.setup_middlewares () # overwrite to add middlewares
105+
self.setup_default_routes ()
106+
self.setup_static_routes ()
107+
self.setup_routes () # overwrite to add routes
70108
return self
71109

72110
@index_by
73-
def routes(self, include_default=False):
74-
return self.fast_api_utils().fastapi_routes(include_default=include_default)
111+
def routes(self, include_default=False, expand_mounts=False):
112+
return self.fast_api_utils().fastapi_routes(include_default=include_default, expand_mounts=expand_mounts)
113+
114+
def route_remove(self, path):
115+
for route in self.app().routes:
116+
if getattr(route, 'path', '') == path:
117+
self.app().routes.remove(route)
118+
print(f'removed route: {path} : {route}')
119+
return True
120+
return False
75121

76122
def routes_methods(self):
77123
return list_set(self.routes(index_by='method_name'))
78124

79125

80-
def routes_paths(self, include_default=False):
81-
paths = list_set(self.routes(index_by='http_path'))
82-
if include_default:
83-
return paths
84-
return list_minus_list(list_a=paths, list_b=DEFAULT_ROUTES_PATHS)
126+
def routes_paths(self, include_default=False, expand_mounts=False):
127+
routes_paths = self.routes(include_default=include_default, expand_mounts=expand_mounts)
128+
return list_set(list_index_by(routes_paths, 'http_path'))
129+
# paths = list_set(self.routes(index_by='http_path'))
130+
# if include_default:
131+
# return paths
132+
# return list_minus_list(list_a=paths, list_b=DEFAULT_ROUTES_PATHS)
133+
134+
def setup_middlewares(self): # overwrite to add more middlewares
135+
self.setup_middleware__http_events()
136+
if self.enable_cors:
137+
self.setup_middleware__cors()
138+
return self
85139

86-
def setup_middlewares(self): return self # overwrite to add middlewares
87140
def setup_routes (self): return self # overwrite to add rules
88141

89142

90143
def setup_default_routes(self):
91-
self.setup_add_root_route()
92-
self.add_routes(Routes_Config)
144+
if self.default_routes:
145+
self.setup_add_root_route()
146+
self.add_routes(Routes_Config)
93147

94148
def setup_add_root_route(self):
95149
def redirect_to_docs():
@@ -104,10 +158,6 @@ def setup_static_routes(self):
104158
path_name = "static"
105159
self.app().mount(path_static, StaticFiles(directory=path_static_folder), name=path_name)
106160

107-
def setup_default_middlewares(self):
108-
if self.enable_cors:
109-
self.setup_middleware__cors()
110-
111161
def setup_middleware__cors(self): # todo: double check that this is working see bug test
112162
self.app().add_middleware(CORSMiddleware,
113163
allow_origins = ["*"] ,
@@ -116,6 +166,12 @@ def setup_middleware__cors(self): # todo: double check that this i
116166
allow_headers = ["Content-Type", "X-Requested-With", "Origin", "Accept", "Authorization"],
117167
expose_headers = ["Content-Type", "X-Requested-With", "Origin", "Accept", "Authorization"])
118168

169+
def setup_middleware__http_events(self, ):
170+
# note the invocation order is the reverse of the order they are added
171+
self.app().add_middleware(Middleware__Http_Request__Trace_Calls, http_events=self.http_events)
172+
self.app().add_middleware(Middleware__Http_Request__Duration , http_events=self.http_events)
173+
self.app().add_middleware(Middleware__Http_Request , http_events=self.http_events)
174+
return self
119175

120176
def user_middlewares(self):
121177
middlewares = []

osbot_fast_api/api/Fast_API_Routes.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from fastapi import APIRouter
1+
from fastapi import APIRouter, FastAPI
2+
from osbot_utils.base_classes.Type_Safe import Type_Safe
3+
24
from osbot_utils.decorators.lists.index_by import index_by
35

46
from osbot_utils.utils.Misc import lower
@@ -9,14 +11,15 @@
911

1012
#DEFAULT_ROUTES = ['/docs', '/docs/oauth2-redirect', '/openapi.json', '/redoc']
1113

12-
class Fast_API_Routes:
14+
class Fast_API_Routes(Type_Safe): # refactor to Fast_API__Routes
15+
router : APIRouter
16+
app : FastAPI = None
17+
prefix : str
18+
tag : str
1319

14-
def __init__(self, app, tag):
15-
self.router = APIRouter()
16-
self.app = app
17-
self.prefix = f'/{lower(str_safe(tag))}'
18-
self.tag = tag
19-
self.setup()
20+
def __init__(self, **kwargs):
21+
super().__init__(**kwargs)
22+
self.prefix = f'/{lower(str_safe(self.tag))}'
2023

2124
def add_route(self,function, methods):
2225
path = '/' + function.__name__.replace('_', '-')
@@ -45,6 +48,7 @@ def routes_paths(self):
4548
def setup(self):
4649
self.setup_routes()
4750
self.app.include_router(self.router, prefix=self.prefix, tags=[self.tag])
51+
return self
4852

4953
def setup_routes(self): # overwrite this to add routes to self.router
5054
pass
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import threading
2+
from collections import deque
3+
4+
from osbot_utils.base_classes.Type_Safe import Type_Safe
5+
from fastapi import Request
6+
from starlette.responses import Response, StreamingResponse
7+
8+
from osbot_utils.helpers.trace.Trace_Call import Trace_Call
9+
from osbot_utils.helpers.trace.Trace_Call__Config import Trace_Call__Config
10+
from osbot_utils.utils.Dev import pprint
11+
from osbot_utils.utils.Misc import random_guid
12+
from osbot_utils.utils.Objects import base_types, pickle_to_bytes, pickle_from_bytes
13+
14+
HEADER_NAME__FAST_API_REQUEST_ID = 'fast-api-request-id'
15+
HTTP_EVENTS__MAX_REQUESTS_LOGGED = 100
16+
17+
class Fast_API__Http_Events(Type_Safe):
18+
log_requests : bool = False
19+
trace_calls : bool = False
20+
trace_call_config : Trace_Call__Config
21+
requests_data : dict
22+
requests_order : deque
23+
max_requests_logged : int = HTTP_EVENTS__MAX_REQUESTS_LOGGED
24+
fast_api_name : str
25+
add_header_request_id : bool = True
26+
27+
def on_http_duration(self, request, request_duration):
28+
self.log_request_message(request, f'{request.state.request_id} took : {request_duration.get("duration")} seconds')
29+
30+
def on_http_request(self, request: Request):
31+
request_id = self.request_id(request)
32+
33+
request_url = request.url
34+
request_method = request.method
35+
thread_id = self.current_thread_id()
36+
37+
self.log_request_message(request, f'>> on_http_request {thread_id} : {self.fast_api_name} | {request_id} with {len(self.requests_data)} requests, for url: {request_method} {request_url}')
38+
39+
def on_http_response(self, request: Request, response:Response):
40+
request_id = request.state.request_id
41+
self.set_response_headers(request, response)
42+
self.log_request_message(request, f'** on_http_response :{self.fast_api_name} | {request_id} with {len(self.requests_data)} requests, for url: {request.method} {request.url}')
43+
44+
def on_http_trace_start(self, request: Request):
45+
#print(">>>>>> on on_http_trace_start")
46+
self.request_trace_start(request)
47+
48+
def on_http_trace_stop(self, request: Request, response: Response): # pragma: no cover
49+
if StreamingResponse not in base_types(response): # handle the special case when the response is a StreamingResponse
50+
self.request_trace_stop(request)
51+
52+
def current_thread_id(self):
53+
return threading.current_thread().native_id
54+
55+
def log_request_message(self, request, message):
56+
if self.log_requests:
57+
request_data = self.request_data(request)
58+
request_data['messages'].append(message)
59+
60+
def on_response_stream_completed(self, request):
61+
self.request_trace_stop(request)
62+
#state = request.state._state
63+
#print(f">>>>> on on_response_stream_end : {state}")
64+
65+
def request_data(self, request: Request): # todo: refactor all this request_data into a Request_Data class
66+
request_id = self.request_id(request)
67+
request_data = self.requests_data.get(request_id)
68+
if not request_data:
69+
request_data = dict(request_id = request_id , # todo: this is should be creation of a new Fast_API__Request_Data object
70+
request_url = request.url.path ,
71+
messages = [] ,
72+
traces = [] )
73+
self.requests_data[request_id] = request_data
74+
self.requests_order.append(request_id)
75+
return request_data
76+
77+
78+
def request_id(self, request):
79+
if hasattr(request.state, "request_id"):
80+
return request.state.request_id
81+
else:
82+
return self.set_request_id(request)
83+
84+
def request_messages(self, request):
85+
request_id = self.request_id(request)
86+
return self.requests_data.get(request_id, {}).get('messages', [])
87+
88+
def request_trace_start(self, request):
89+
if self.trace_calls:
90+
trace_call_config = self.trace_call_config
91+
trace_call = Trace_Call(config=trace_call_config)
92+
trace_call.start()
93+
request.state.trace_call = trace_call
94+
95+
def request_trace_stop(self, request: Request): # pragma: no cover
96+
if self.trace_calls:
97+
request_id: str = self.request_id(request)
98+
trace_call: Trace_Call = request.state.trace_call
99+
trace_call.stop()
100+
101+
if self.log_requests:
102+
self.log_request_message(request, f'{request_id} on trace stop: {trace_call}')
103+
self.request_traces_append(request)
104+
105+
def request_traces_view_model(self, request):
106+
request_traces = []
107+
for trace_bytes in self.request_data(request).get('traces'): # support for multiple trace's runs
108+
request_traces.extend(pickle_from_bytes(trace_bytes))
109+
return request_traces
110+
111+
def request_traces_append(self, request):
112+
if self.log_requests:
113+
trace_call: Trace_Call = request.state.trace_call
114+
request_id = request.state.request_id
115+
request_data = self.requests_data.get(request_id)
116+
view_model = trace_call.view_data()
117+
view_model_bytes = pickle_to_bytes(view_model)
118+
request_data['traces'].append(view_model_bytes)
119+
return self
120+
121+
def set_request_id(self, request):
122+
request_id = random_guid()
123+
request.state.request_id = request_id
124+
request.state.http_events = self # todo: see if this is best place to put this
125+
return request_id
126+
127+
def set_response_headers(self, request: Request, response:Response):
128+
if self.add_header_request_id and response:
129+
request_id = request.state.request_id
130+
response.headers[HEADER_NAME__FAST_API_REQUEST_ID] = request_id
131+
return self
132+

0 commit comments

Comments
 (0)