Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/refactor!: router-level middleware, support for lifespans on mounted apps #35

Merged
merged 5 commits into from
Feb 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs_src/tutorial/lifespan.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import AsyncGenerator

Expand All @@ -13,6 +14,7 @@ class Config:
ConfigDep = Annotated[Config, Dependant(scope="app")]


@asynccontextmanager
async def lifespan(config: ConfigDep) -> AsyncGenerator[None, None]:
print(config.token)
yield
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "xpresso"
version = "0.7.3"
version = "0.8.0"
description = "A developer centric, performant Python web framework"
authors = ["Adrian Garcia Badaracco <adrian@adriangb.com>"]
readme = "README.md"
Expand Down
15 changes: 8 additions & 7 deletions tests/test_dependency_injection.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import sys

if sys.version_info < (3, 9):
from typing_extensions import Annotated
else:
from typing import Annotated
from contextlib import asynccontextmanager
from typing import AsyncIterator

from starlette.responses import Response
from starlette.testclient import TestClient

from xpresso import App, Dependant, Operation, Path
from xpresso.typing import Annotated


def test_router_route_dependencies() -> None:
Expand Down Expand Up @@ -44,8 +41,12 @@ def test_lifespan_dependencies() -> None:
class Test:
...

async def lifespan(t: Annotated[Test, Dependant(scope="app")]) -> None:
@asynccontextmanager
async def lifespan(
t: Annotated[Test, Dependant(scope="app")]
) -> AsyncIterator[None]:
app.state.t = t # type: ignore[has-type]
yield

async def endpoint(t: Annotated[Test, Dependant(scope="app")]) -> Response:
assert app.state.t is t # type: ignore[has-type]
Expand Down
4 changes: 2 additions & 2 deletions tests/test_docs/tutorial/routing/test_tutorial002.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def test_openapi() -> None:
"/v1/items": {
"get": {
"responses": {"404": {"description": "Item not found"}},
"tags": ["read", "v1", "items"],
"tags": ["v1", "items", "read"],
"summary": "List all items",
"description": "The **items** operation",
"deprecated": True,
Expand All @@ -32,7 +32,7 @@ def test_openapi() -> None:
},
},
},
"tags": ["write", "v1", "items"],
"tags": ["v1", "items", "write"],
"description": "Documentation from docstrings!\n You can use any valid markdown, for example lists:\n\n - Point 1\n - Point 2\n ",
"requestBody": {
"content": {
Expand Down
42 changes: 42 additions & 0 deletions tests/test_exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from xpresso import App, HTTPException, Path, Request
from xpresso.responses import JSONResponse
from xpresso.testclient import TestClient


def test_override_base_error_handler() -> None:
async def custom_server_error_from_exception(request: Request, exc: Exception):
return JSONResponse(
{"detail": "Custom Server Error from Exception"}, status_code=500
)

async def raise_exception() -> None:
raise Exception

async def custom_server_error_from_500(request: Request, exc: Exception):
return JSONResponse(
{"detail": "Custom Server Error from HTTPException(500)"}, status_code=500
)

async def raise_500() -> None:
raise HTTPException(500)

app = App(
routes=[
Path("/raise-exception", get=raise_exception),
Path("/raise-500", get=raise_500),
],
exception_handlers={
Exception: custom_server_error_from_exception,
500: custom_server_error_from_500,
},
)

client = TestClient(app)

resp = client.get("/raise-exception")
assert resp.status_code == 500, resp.content
assert resp.json() == {"detail": "Custom Server Error from Exception"}

resp = client.get("/raise-500")
assert resp.status_code == 500, resp.content
assert resp.json() == {"detail": "Custom Server Error from HTTPException(500)"}
38 changes: 38 additions & 0 deletions tests/test_lifespans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator, List

from xpresso import App, Dependant, Router
from xpresso.routing.mount import Mount
from xpresso.testclient import TestClient


def test_lifespan_mounted_app() -> None:
class Counter(List[int]):
pass

@asynccontextmanager
async def lifespan(counter: Counter) -> AsyncIterator[None]:
counter.append(1)
yield

counter = Counter()

inner_app = App(lifespan=lifespan)
inner_app.container.register_by_type(
Dependant(lambda: counter, scope="app"), Counter
)

app = App(
routes=[
Mount("/mounted-app", app=inner_app),
Mount("/mounted-router", app=Router([], lifespan=lifespan)),
],
lifespan=lifespan,
)

app.container.register_by_type(Dependant(lambda: counter, scope="app"), Counter)

with TestClient(app):
pass

assert counter == [1, 1, 1]
171 changes: 167 additions & 4 deletions tests/test_routing/test_mounts.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Tests for experimental OpenAPI inspired routing"""
from typing import Any, Dict

from xpresso import App, FromPath, Path
from di import BaseContainer

from xpresso import App, Dependant, FromPath, Path
from xpresso.routing.mount import Mount
from xpresso.testclient import TestClient

Expand Down Expand Up @@ -126,7 +128,7 @@ def test_openapi_routing_for_mounted_path() -> None:
assert resp.json() == expected_openapi


def test_xpresso_app_as_app_param_to_mount_routing() -> None:
def test_mounted_xpresso_app_routing() -> None:
# not a use case we advertise
# but we want to know what the behavior is
app = App(
Expand All @@ -152,7 +154,7 @@ def test_xpresso_app_as_app_param_to_mount_routing() -> None:
assert resp.json() == 124


def test_xpresso_app_as_app_param_to_mount_openapi() -> None:
def test_mounted_xpresso_app_openapi() -> None:
# not a use case we advertise
# but we want to know what the behavior is
app = App(
Expand All @@ -176,9 +178,170 @@ def test_xpresso_app_as_app_param_to_mount_openapi() -> None:
expected_openapi: Dict[str, Any] = {
"openapi": "3.0.3",
"info": {"title": "API", "version": "0.1.0"},
"paths": {},
"paths": {
"/mount/{number}": {
"get": {
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {"title": "Response", "type": "integer"}
}
},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
"parameters": [
{
"required": True,
"style": "simple",
"explode": False,
"schema": {"title": "Number", "type": "integer"},
"name": "number",
"in": "path",
}
],
}
}
},
"components": {
"schemas": {
"ValidationError": {
"title": "ValidationError",
"required": ["loc", "msg", "type"],
"type": "object",
"properties": {
"loc": {
"title": "Location",
"type": "array",
"items": {
"oneOf": [{"type": "string"}, {"type": "integer"}]
},
},
"msg": {"title": "Message", "type": "string"},
"type": {"title": "Error Type", "type": "string"},
},
},
"HTTPValidationError": {
"title": "HTTPValidationError",
"type": "object",
"properties": {
"detail": {
"title": "Detail",
"type": "array",
"items": {"$ref": "#/components/schemas/ValidationError"},
}
},
},
}
},
}

resp = client.get("/openapi.json")
assert resp.status_code == 200, resp.content
assert resp.json() == expected_openapi


def test_mounted_xpresso_app_dependencies_isolated_containers() -> None:
# not a use case we advertise
# but we want to know what the behavior is

class Thing:
def __init__(self, value: str = "default") -> None:
self.value = value

async def endpoint(thing: Thing) -> str:
return thing.value

inner_app = App(
routes=[
Path(
path="/",
get=endpoint,
)
],
)

app = App(
routes=[
Mount(
path="/mount",
app=inner_app,
),
Path("/top-level", get=endpoint),
]
)

app.container.register_by_type(
Dependant(lambda: Thing("injected")),
Thing,
)

client = TestClient(app)

resp = client.get("/top-level")
assert resp.status_code == 200, resp.content
assert resp.json() == "injected"

resp = client.get("/mount")
assert resp.status_code == 200, resp.content
assert resp.json() == "default"


def test_mounted_xpresso_app_dependencies_shared_containers() -> None:
# not a use case we advertise
# but we want to know what the behavior is

class Thing:
def __init__(self, value: str = "default") -> None:
self.value = value

async def endpoint(thing: Thing) -> str:
return thing.value

container = BaseContainer(scopes=("app", "connection", "operation"))
container.register_by_type(
Dependant(lambda: Thing("injected")),
Thing,
)

inner_app = App(
routes=[
Path(
path="/",
get=endpoint,
)
],
container=container,
)

app = App(
routes=[
Mount(
path="/mount",
app=inner_app,
),
Path("/top-level", get=endpoint),
],
container=container,
)

client = TestClient(app)

resp = client.get("/top-level")
assert resp.status_code == 200, resp.content
assert resp.json() == "injected"

resp = client.get("/mount")
assert resp.status_code == 200, resp.content
assert resp.json() == "injected"
Loading