diff --git a/examples/starlette.py b/examples/starlette.py index 7086861..7699c4c 100644 --- a/examples/starlette.py +++ b/examples/starlette.py @@ -3,7 +3,7 @@ from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response -from starlette.routing import Route +from starlette.routing import Mount, Route, WebSocketRoute from starlette.templating import Jinja2Templates from starception import install_error_handler @@ -72,5 +72,23 @@ def css_view(request: Request) -> Response: Route('/css', css_view), Route('/template', template_view), Route('/javascript', javascript_view), + Mount( + '/app', + routes=[ + Route('/node', index_view, name='index1'), + Mount( + '/nested', + routes=[ + Route('/node/{id:int}', index_view, name='nested_node'), + Route('/node/{id:uuid}-{user:str}-{counter:int}', index_view, name='params_node'), + Route('/node2', index_view, name='nested_node'), + Mount('/level2', routes=[Route('/node/{id}', index_view)]), + ], + name='nested', + ), + ], + ), + Mount('/subapp', app=Starlette(), name='subapp'), + WebSocketRoute('/ws', index_view), ], ) diff --git a/starception/exception_handler.py b/starception/exception_handler.py index f7f093a..cdf1518 100644 --- a/starception/exception_handler.py +++ b/starception/exception_handler.py @@ -15,6 +15,8 @@ from starlette.middleware.errors import ServerErrorMiddleware from starlette.requests import Request from starlette.responses import HTMLResponse, PlainTextResponse, Response +from starlette.routing import BaseRoute, Mount, NoMatchFound, Router +from starlette.types import Receive, Scope, Send from urllib.parse import quote_plus _editor: str = 'none' @@ -24,6 +26,53 @@ } +@dataclasses.dataclass +class RouteInfo: + methods: typing.List[str] + type: str + middleware: typing.List[str] + path_name: str + path_pattern: str + endpoint: typing.Any + + @property + def endpoint_name(self) -> str: + module_name = getattr(self.endpoint, '__module__') + base_name = getattr(self.endpoint, '__name__', getattr(self.endpoint.__class__, '__name__')) + return f'{module_name}.{base_name}' + + +def collect_routes(routes: typing.Iterable[BaseRoute], prefix: str = '') -> typing.Iterable[RouteInfo]: + for route in routes: + if isinstance(route, Mount) and len(route.routes): + yield from collect_routes(list(route.routes), route.path_format) + else: + endpoint = getattr(route, 'endpoint', getattr(route, 'app')) + yield RouteInfo( + methods=getattr(route, 'methods', []) or [], + type=type(route).__name__, + path_name=getattr(route, 'name', ''), + path_pattern=prefix + getattr(route, 'path_format', ''), + endpoint=endpoint, + middleware=[], + ) + + +def not_found_renderer(request: Request, exc: BaseException) -> str: + routes = list(collect_routes(request.app.routes)) + return jinja.get_template('custom_exceptions/not_found.html').render( + { + 'routes': routes, + } + ) + + +ExceptionRenderer = typing.Callable[[Request, BaseException], str] +exception_renderers: typing.Dict[typing.Type[BaseException], ExceptionRenderer] = { + NoMatchFound: not_found_renderer, +} + + def set_editor(name: str) -> None: """ Set code editor. @@ -51,7 +100,10 @@ def to_ide_link(path: str, lineno: int) -> str: return template.format(path=path, lineno=lineno) -def install_error_handler(editor: str = '') -> None: +def install_error_handler( + editor: str = '', + custom_exception_renderers: typing.Optional[typing.Dict[typing.Type[BaseException], ExceptionRenderer]] = None, +) -> None: """ Replace Starlette debug exception handler in-place. @@ -60,10 +112,22 @@ def install_error_handler(editor: str = '') -> None: """ set_editor(editor) + if custom_exception_renderers: + exception_renderers.update(custom_exception_renderers) + def bound_handler(self: ServerErrorMiddleware, request: Request, exc: Exception) -> Response: return exception_handler(request, exc) + original_handler = getattr(Router, 'not_found') + + async def patched_not_found(self: Router, scope: Scope, receive: Receive, send: Send) -> None: + if 'app' in scope and scope['app'].debug: + raise NoMatchFound('', {}) + else: + await original_handler(self, scope, receive, send) + setattr(ServerErrorMiddleware, 'debug_response', bound_handler) + setattr(Router, 'not_found', patched_not_found) def get_relative_filename(path: str) -> str: @@ -247,6 +311,14 @@ def generate_html(request: Request, exc: Exception, limit: int = 15) -> str: ) cause = getattr(cause, '__cause__') + custom_exception_block = '' + exception_renderer = exception_renderers.get(exc.__class__) + if exception_renderer: + try: + custom_exception_block = exception_renderer(request, exc) + except Exception as ex: + custom_exception_block = f"Got exception while rendering exception details: {ex.__class__.__name__} {ex}." + template = jinja.get_template('index.html') return template.render( { @@ -290,6 +362,7 @@ def generate_html(request: Request, exc: Exception, limit: int = 15) -> str: "Paths": Markup("
".join(sys.path)), }, 'environment': os.environ, + 'custom_exception_block': custom_exception_block, 'solution': getattr(exc, 'solution', None), } ) diff --git a/starception/templates/custom_exceptions/not_found.html b/starception/templates/custom_exceptions/not_found.html new file mode 100644 index 0000000..9cf2044 --- /dev/null +++ b/starception/templates/custom_exceptions/not_found.html @@ -0,0 +1,35 @@ +Available routes +
+
+ + + + + + + + + + + + {% for route in routes %} + + + + + + + + {% endfor %} + +
NameMethodsPath patternEndpointType
+ {{ route.path_name }} + + {{ route.methods|join(', ') }} + + {{ route.path_pattern }} + + {{ route.endpoint_name }} + + {{ route.type }} +
diff --git a/starception/templates/index.html b/starception/templates/index.html index 1b50d92..ff6da60 100644 --- a/starception/templates/index.html +++ b/starception/templates/index.html @@ -65,6 +65,15 @@ {% endif %} + + {% if loop.index0 == 0 and custom_exception_block %} +
+
+ {{ custom_exception_block }} +
+
+ {% endif %} +
0 %} class="collapsed"{% endif %}> {% if loop.index0 > 0 %} {% if stack_item.solution %} diff --git a/starception/templates/styles.css b/starception/templates/styles.css index e24417f..54be7f8 100644 --- a/starception/templates/styles.css +++ b/starception/templates/styles.css @@ -376,3 +376,20 @@ dd { .search-links a:hover { text-decoration: underline; } + +.table { + width: 100%; +} + +.table th, .table td { + text-align: left; + padding: 3px 8px; +} + +.table tbody tr td { + border-top: 1px solid var(--data-list-border-color); +} + +.table tbody tr:hover { + background-color: var(--data-list-bg-hover); +}