Skip to content

Commit

Permalink
✨ NotFoundContext and tests for data structures
Browse files Browse the repository at this point in the history
  • Loading branch information
perdy committed Jan 19, 2023
1 parent 836f7be commit f6bdd34
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 16 deletions.
55 changes: 46 additions & 9 deletions flama/debug/data_structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,22 @@

if t.TYPE_CHECKING:
from flama import http
from flama.applications import Flama


@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class RequestParams:
path: t.Dict[str, t.Any]
query: t.Dict[str, t.Any]


@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class RequestClient:
host: str
port: int


@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class Request:
path: str
method: str
Expand All @@ -41,7 +42,7 @@ def from_request(cls, request: "http.Request") -> "Request":
)


@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class Frame:
filename: str
function: str
Expand Down Expand Up @@ -78,7 +79,7 @@ def from_frame_info(cls, frame: inspect.FrameInfo) -> "Frame":
)


@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class Error:
error: str
description: str
Expand All @@ -95,7 +96,7 @@ def from_exception(cls, exc: Exception, context: int = 10) -> "Error":
)


@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class Environment:
platform: str
python: str
Expand All @@ -107,16 +108,52 @@ def from_system(cls) -> "Environment":
return cls(platform=sys.platform, python=sys.executable, python_version=sys.version, path=sys.path)


@dataclasses.dataclass
@dataclasses.dataclass(frozen=True)
class Endpoint:
path: str
endpoint: str
name: t.Optional[str] = None


@dataclasses.dataclass(frozen=True)
class App:
urls: t.List[t.Union[Endpoint, "App"]]
path: str
name: t.Optional[str] = None

@classmethod
def from_app(cls, app: t.Any, path: str = "/", name: t.Optional[str] = None) -> "App":
urls: t.List[t.Union[Endpoint, "App"]] = []
for route in app.routes:
try:
urls.append(App.from_app(route, path=route.path, name=route.name))
except AttributeError:
urls.append(Endpoint(route.path, route.app, route.name))

return cls(urls=urls, path=path, name=name)


@dataclasses.dataclass(frozen=True)
class ErrorContext:
request: Request
error: Error
environment: Environment
error: Error

@classmethod
def build(cls, request: "http.Request", exc: Exception) -> "ErrorContext":
return cls(
request=Request.from_request(request),
error=Error.from_exception(exc),
environment=Environment.from_system(),
error=Error.from_exception(exc),
)


@dataclasses.dataclass(frozen=True)
class NotFoundContext:
request: Request
environment: Environment
urls: App

@classmethod
def build(cls, request: "http.Request", app: "Flama") -> "NotFoundContext":
return cls(request=Request.from_request(request), environment=Environment.from_system(), urls=App.from_app(app))
10 changes: 7 additions & 3 deletions flama/debug/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import starlette.exceptions

from flama import concurrency, exceptions, http, websockets
from flama.debug.data_structures import ErrorContext
from flama.debug.data_structures import ErrorContext, NotFoundContext

if t.TYPE_CHECKING:
from flama import types
Expand Down Expand Up @@ -73,7 +73,7 @@ def debug_handler(

if "text/html" in accept:
return http._ReactTemplateResponse(
"debug/error_500.html", context=dataclasses.asdict(ErrorContext.build(request, exc))
"debug/error_500.html", context=dataclasses.asdict(ErrorContext.build(request, exc)), status_code=500
)
return http.PlainTextResponse("Internal Server Error", status_code=500)

Expand Down Expand Up @@ -148,7 +148,11 @@ def http_exception_handler(
accept = request.headers.get("accept", "")

if self.debug and exc.status_code == 404 and "text/html" in accept:
return http.PlainTextResponse(content=exc.detail, status_code=exc.status_code)
return http._ReactTemplateResponse(
template="debug/error_404.html",
context=dataclasses.asdict(NotFoundContext.build(request, scope["app"])),
status_code=404,
)

return http.APIErrorResponse(detail=exc.detail, status_code=exc.status_code, exception=exc)

Expand Down
119 changes: 119 additions & 0 deletions tests/debug/test_data_structures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import dataclasses
from unittest.mock import MagicMock, patch

import pytest

from flama import http
from flama.applications import Flama
from flama.debug.data_structures import App, Environment, Error, ErrorContext, NotFoundContext, Request
from flama.routing import Mount, Route


@pytest.fixture
def http_request():
return MagicMock()


class TestCaseRequest:
def test_from_request(self, asgi_scope, asgi_receive, asgi_send):
request = http.Request(asgi_scope, asgi_receive, asgi_send)

result = dataclasses.asdict(Request.from_request(request))

assert result == {
"client": None,
"cookies": {},
"headers": {},
"method": "GET",
"params": {"path": {}, "query": {}},
"path": "",
}


class TestCaseError:
def test_from_exception(self):
try:
raise ValueError("Foo")
except ValueError as e:
result = dataclasses.asdict(Error.from_exception(e))

traceback = result.pop("traceback", None)
assert traceback
assert len(traceback) == 1
frame = traceback[0]
code = frame.pop("code", None)
assert code
assert frame == {
"filename": "tests/debug/test_data_structures.py",
"function": "test_from_exception",
"line": 36,
"vendor": None,
}
assert result == {"description": "Foo", "error": "ValueError"}


class TestCaseEnvironment:
def test_from_system(self):
result = dataclasses.asdict(Environment.from_system())

path = result.pop("path", None)
assert path
assert result == {
"platform": "linux",
"python": "/home/perdy/Desarrollo/perdy/flama/.venv/bin/python",
"python_version": "3.10.8 (main, Oct 11 2022, 20:04:56) [GCC 12.2.0]",
}


class TestCaseApp:
def test_from_app(self):
foo_route = Route("/", lambda: None, name="foo")
bar_route = Route("/", lambda: None, name="bar")
app = Flama(
routes=[foo_route, Mount("/subapp/", routes=[bar_route], name="subapp")],
schema=None,
docs=None,
)

result = dataclasses.asdict(App.from_app(app))

assert result == {
"name": None,
"path": "/",
"urls": [
{"path": "/", "endpoint": foo_route.app, "name": "foo"},
{
"name": "subapp",
"path": "/subapp",
"urls": [{"path": "/", "endpoint": bar_route.app, "name": "bar"}],
},
],
}


class TestCaseErrorContext:
def test_build(self):
request_mock = MagicMock(Request)
environment_mock = MagicMock(Environment)
error_mock = MagicMock(Error)

with patch.object(Request, "from_request", return_value=request_mock), patch.object(
Environment, "from_system", return_value=environment_mock
), patch.object(Error, "from_exception", return_value=error_mock):
context = dataclasses.asdict(ErrorContext.build(MagicMock(), MagicMock()))

assert context == {"request": request_mock, "environment": environment_mock, "error": error_mock}


class TestCaseNotFoundContext:
def test_build(self):
request_mock = MagicMock(Request)
environment_mock = MagicMock(Environment)
app_mock = MagicMock(App)

with patch.object(Request, "from_request", return_value=request_mock), patch.object(
Environment, "from_system", return_value=environment_mock
), patch.object(App, "from_app", return_value=app_mock):
context = dataclasses.asdict(NotFoundContext.build(MagicMock(), MagicMock()))

assert context == {"request": request_mock, "environment": environment_mock, "urls": app_mock}
12 changes: 8 additions & 4 deletions tests/debug/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import starlette.exceptions

from flama import exceptions, http, types, websockets
from flama.applications import Flama
from flama.debug.data_structures import ErrorContext
from flama.debug.middleware import BaseErrorMiddleware, ExceptionMiddleware, ServerErrorMiddleware

Expand Down Expand Up @@ -124,7 +125,7 @@ def test_debug_response_html(self, middleware, asgi_scope, asgi_receive, asgi_se
assert ErrorContext.build.call_count == 1
assert dataclasses_dict.call_args_list == [call(error_context_mock)]
assert isinstance(response, http._ReactTemplateResponse)
assert response_mock.call_args_list == [call("debug/error_500.html", context=context_mock)]
assert response_mock.call_args_list == [call("debug/error_500.html", context=context_mock, status_code=500)]

def test_debug_response_text(self, middleware, asgi_scope, asgi_receive, asgi_send):
exc = ValueError()
Expand Down Expand Up @@ -285,8 +286,8 @@ async def test_process_exception(
True,
b"text/html",
exceptions.HTTPException(404, "Foo"),
http.PlainTextResponse,
{"content": "Foo", "status_code": 404},
http._ReactTemplateResponse,
{"template": "debug/error_404.html", "context": {}, "status_code": 404},
id="debug_404",
),
pytest.param(
Expand All @@ -303,6 +304,7 @@ def test_http_exception_handler(
self, middleware, asgi_scope, asgi_receive, asgi_send, debug, accept, exc, response_class, response_params
):
asgi_scope["type"] = "http"
asgi_scope["app"] = MagicMock(Flama)
middleware.debug = debug

if accept:
Expand All @@ -311,7 +313,9 @@ def test_http_exception_handler(
if response_class == http.APIErrorResponse:
response_params["exception"] = exc

with patch(f"flama.debug.middleware.http.{response_class.__name__}", spec=response_class) as response_mock:
with patch(
f"flama.debug.middleware.http.{response_class.__name__}", spec=response_class
) as response_mock, patch("flama.debug.middleware.dataclasses.asdict", return_value={}):
response = middleware.http_exception_handler(asgi_scope, asgi_receive, asgi_send, exc)

assert isinstance(response, response_class)
Expand Down

0 comments on commit f6bdd34

Please sign in to comment.