Skip to content

Commit

Permalink
mount improvements 🐑
Browse files Browse the repository at this point in the history
  • Loading branch information
RobertoPrevato authored Mar 12, 2022
1 parent 5c1bf8a commit 0fed9b3
Show file tree
Hide file tree
Showing 13 changed files with 562 additions and 47 deletions.
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.5] - 2022-03-??
## [1.2.5] - 2022-03-12 :dove:
* Improves WebSocket to handle built-in exception types: Unauthorized, HTTPException
* Adds built-in support for [Anti Forgery validation](https://www.neoteroi.dev/blacksheep/anti-request-forgery) to protect against Cross-Site Request Forgery (XSRF/CSRF) attacks
* Modifies the Request and Response classes to support weak references
* Adds the possibility to use `**kwargs` in view functions, returning HTML built
using Jinja2
* Adds support for automatic handling of child application events when BlackSheep
applications are mounted into a parent BlackSheep application
* Adds support for OpenAPI Documentation generated for children BlackSheep apps,
when using mounts
* Corrects bugs that prevented mounted routes to work recursively in descendants
* Updates dependencies

## [1.2.4] - 2022-02-13 :cat:
* Modifies the `WebSocket` class to support built-in binders
Expand Down Expand Up @@ -61,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgrades `Jinja2` dependency to version `3.0.2`
- Modifies `setup.py` dependencies to be less strict (`~=` instead of `==`)

## [1.0.9] - 2021-07-14 :italy:
## [1.0.9] - 2021-07-14 🇮🇹
- Adds support for application mounts (see [discussion #160](https://github.com/Neoteroi/BlackSheep/discussions/160))
- Applies sorting of imports using `isort`, enforces linters in the CI pipeline
with both `black` and `isort`
Expand Down
103 changes: 81 additions & 22 deletions blacksheep/server/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@
from blacksheep.server.files.dynamic import serve_files_dynamic
from blacksheep.server.normalization import normalize_handler, normalize_middleware
from blacksheep.server.responses import _ensure_bytes
from blacksheep.server.routing import Mount, RegisteredRoute, Router, RoutesRegistry
from blacksheep.server.routing import (
MountRegistry,
RegisteredRoute,
Router,
RoutesRegistry,
)
from blacksheep.server.websocket import WebSocket
from blacksheep.sessions import Encryptor, SessionMiddleware, SessionSerializer, Signer
from blacksheep.utils import ensure_bytes, join_fragments
Expand Down Expand Up @@ -146,27 +151,29 @@ def _extend(obj, cls):
obj.__class__ = type(base_cls_name, (cls, base_cls), {})


env_settings = EnvironmentSettings()


class Application(BaseApplication):
"""
Server application class.
"""

def __init__(
self,
*,
router: Optional[Router] = None,
services: Optional[Container] = None,
debug: bool = False,
show_error_details: Optional[bool] = None,
mount: Optional[Mount] = None,
mount: Optional[MountRegistry] = None,
):
env_settings = EnvironmentSettings()
if router is None:
router = Router()
if services is None:
services = Container()
if show_error_details is None:
show_error_details = env_settings.show_error_details
if mount is None:
mount = Mount()
mount = MountRegistry(env_settings.mount_auto_events)
super().__init__(show_error_details, router)

self.services: Container = services
Expand All @@ -187,15 +194,8 @@ def __init__(
self.files_handler = FilesHandler()
self.server_error_details_handler = ServerErrorDetailsHandler()
self._session_middleware: Optional[SessionMiddleware] = None
self._mount = mount

def mount(self, path: str, app: Callable) -> None:
self._mount.mount(path, app)

if len(self._mount.mounted_apps) == 1:
# the first time a mount is configured, extend the application
# to use mounts when handling web requests
self.extend(MountMixin)
self.base_path: str = ""
self._mount_registry = mount

@property
def service_provider(self) -> Services:
Expand Down Expand Up @@ -223,6 +223,60 @@ def cors(self) -> CORSStrategy:
)
return self._cors_strategy

@property
def mount_registry(self) -> MountRegistry:
return self._mount_registry

def mount(self, path: str, app: Callable) -> None:
"""
Mounts an ASGI application at the given path. When a web request has a URL path
that starts with the mount path, it is handled by the mounted app (the mount
path is stripped from the final URL path received by the child application).
If the child application is a BlackSheep application, it requires handling of
its lifecycle events. This can be automatic, if the environment variable
APP_MOUNT_AUTO_EVENTS is set to "1" or "true" (case insensitive)
or explicitly enabled, if the parent app's is configured this way:
parent_app.mount_registry.auto_events = True
"""
if app is self:
raise TypeError("Cannot mount an application into itself")

self._mount_registry.mount(path, app)

if isinstance(app, Application):
app.base_path = (
join_fragments(self.base_path, path) if self.base_path else path
)

if self._mount_registry.auto_events:
self._bind_child_app_events(app)

if len(self._mount_registry.mounted_apps) == 1:
# the first time a mount is configured, extend the application
# to use mounts when handling web requests
self.extend(MountMixin)

def _bind_child_app_events(self, app: "Application") -> None:
@self.on_start
async def handle_child_app_start(_):
await app.start()

@self.after_start
async def handle_child_app_after_start(_):
await app.after_start.fire()

@self.on_middlewares_configuration
def handle_child_app_on_middlewares_configuration(_):
app.on_middlewares_configuration.fire_sync()

@self.on_stop
async def handle_child_app_stop(_):
await app.stop()

def use_sessions(
self,
secret_key: str,
Expand Down Expand Up @@ -366,10 +420,12 @@ def use_authorization(
strategy.add(Policy("authenticated").add(AuthenticatedRequirement()))

self._authorization_strategy = strategy
self.exceptions_handlers[
AuthenticateChallenge
] = handle_authentication_challenge
self.exceptions_handlers[UnauthorizedError] = handle_unauthorized
self.exceptions_handlers.update(
{ # type: ignore
AuthenticateChallenge: handle_authentication_challenge,
UnauthorizedError: handle_unauthorized,
}
)
return strategy

def route(
Expand Down Expand Up @@ -700,7 +756,8 @@ async def __call__(self, scope, receive, send):


class MountMixin:
_mount: Mount
_mount: MountRegistry
base_path: str

def handle_mount_path(self, scope, route_match):
assert route_match.values is not None
Expand All @@ -723,7 +780,9 @@ async def _handle_redirect_to_mount_root(self, scope, send):
(
b"Location",
_ensure_bytes(
f"{get_request_url_from_scope(scope, trailing_slash=True)}"
get_request_url_from_scope(
scope, trailing_slash=True, base_path=self.base_path
)
),
)
],
Expand All @@ -734,7 +793,7 @@ async def __call__(self, scope, receive, send):
if scope["type"] == "lifespan":
return await super()._handle_lifespan(receive, send) # type: ignore

for route in self._mount.mounted_apps: # type: ignore
for route in self.mount_registry.mounted_apps: # type: ignore
route_match = route.match(scope["raw_path"])
if route_match:
raw_path = scope["raw_path"]
Expand Down
3 changes: 2 additions & 1 deletion blacksheep/server/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

def get_request_url_from_scope(
scope,
base_path: str = "",
include_query: bool = True,
trailing_slash: bool = False,
) -> str:
Expand Down Expand Up @@ -38,7 +39,7 @@ def get_request_url_from_scope(
if not include_query or not scope.get("query_string")
else ("?" + scope.get("query_string").decode("utf8"))
)
return f"{protocol}://{host}{port_part}{path}{query_part}"
return f"{protocol}://{host}{port_part}{base_path}{path}{query_part}"


def get_request_url(request: Request) -> str:
Expand Down
2 changes: 2 additions & 0 deletions blacksheep/server/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ def truthy(value: str, default: bool = False) -> bool:

class EnvironmentSettings:
show_error_details: bool
mount_auto_events: bool

def __init__(self) -> None:
self.show_error_details = truthy(os.environ.get("APP_SHOW_ERROR_DETAILS", ""))
self.mount_auto_events = truthy(os.environ.get("APP_MOUNT_AUTO_EVENTS", ""))
28 changes: 24 additions & 4 deletions blacksheep/server/openapi/v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,23 @@ def generate_documentation(self, app: Application) -> OpenAPI:
info=self.info, paths=self.get_paths(app), components=self.components
)

def get_paths(self, app: Application) -> Dict[str, PathItem]:
return self.get_routes_docs(app.router)
def get_paths(self, app: Application, path_prefix: str = "") -> Dict[str, PathItem]:
own_paths = self.get_routes_docs(app.router, path_prefix)

if app.mount_registry.mounted_apps and app.mount_registry.handle_docs:
for route in app.mount_registry.mounted_apps:
if isinstance(route.handler, Application):
full_prefix = route.pattern.decode().rstrip("/*")
if path_prefix:
full_prefix = path_prefix.rstrip("/") + full_prefix

child_docs = self.get_paths(
route.handler,
full_prefix,
)
own_paths.update(child_docs)

return own_paths

def get_type_name_for_generic(
self,
Expand Down Expand Up @@ -879,7 +894,9 @@ def _apply_docstring(self, handler, docs: Optional[EndpointDocs]) -> None:
def get_operation_id(self, docs: Optional[EndpointDocs], handler) -> str:
return handler.__name__

def get_routes_docs(self, router: Router) -> Dict[str, PathItem]:
def get_routes_docs(
self, router: Router, path_prefix: str = ""
) -> Dict[str, PathItem]:
"""Obtains a documentation object from the routes defined in a router."""
paths_doc: Dict[str, PathItem] = {}
raw_dict = self.router_to_paths_dict(router, lambda route: route)
Expand Down Expand Up @@ -908,7 +925,10 @@ def get_routes_docs(self, router: Router) -> Dict[str, PathItem]:
self.events.on_operation_created.fire_sync(operation)
setattr(path_item, method, operation)

paths_doc[path] = path_item
path_value = path_prefix + path
if path_value != "/" and path_value.endswith("/"):
path_value = path_value.rstrip("/")
paths_doc[path_value] = path_item

self.events.on_paths_created.fire_sync(paths_doc)
return paths_doc
16 changes: 13 additions & 3 deletions blacksheep/server/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,12 +491,18 @@ def add(self, method: str, pattern: str, handler: Callable):
self.routes.append(RegisteredRoute(method, pattern, handler))


class Mount:
__slots__ = ("_mounted_apps", "_mounted_paths")
class MountRegistry:
"""
Holds information about mounted applications and how they should be handled.
"""

def __init__(self):
__slots__ = ("_mounted_apps", "_mounted_paths", "auto_events", "handle_docs")

def __init__(self, auto_events: bool = True, handle_docs: bool = False):
self._mounted_apps = []
self._mounted_paths = set()
self.auto_events = auto_events
self.handle_docs = handle_docs

@property
def mounted_apps(self) -> List[Route]:
Expand All @@ -519,3 +525,7 @@ def mount(self, path: str, app: Callable) -> None:
path = f"{path.rstrip('/*')}/*"

self._mounted_apps.append(Route(path, app))


# For backward compatibility
Mount = MountRegistry
2 changes: 1 addition & 1 deletion blacksheep/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def remove_duplicate_slashes(value: str) -> str:


def join_fragments(*args: AnyStr) -> str:
"""Joins URL fragments bytes"""
"""Joins URL fragments"""
return "/" + "/".join(
remove_duplicate_slashes(ensure_str(arg)).strip("/") for arg in args if arg
)
Expand Down
11 changes: 10 additions & 1 deletion itests/app_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
FromQuery,
FromServices,
)
from blacksheep.server.controllers import ApiController, delete, get, post
from blacksheep.server.controllers import ApiController
from blacksheep.server.openapi.common import (
ContentInfo,
EndpointDocs,
Expand All @@ -44,11 +44,20 @@
from blacksheep.server.openapi.ui import ReDocUIProvider
from blacksheep.server.openapi.v3 import OpenAPIHandler
from blacksheep.server.responses import text
from blacksheep.server.routing import RoutesRegistry
from itests.utils import CrashTest

app_2 = Application()


controllers_router = RoutesRegistry()
app_2.controllers_router = controllers_router

get = controllers_router.get
post = controllers_router.post
delete = controllers_router.delete


# OpenAPI v3 configuration:
docs = OpenAPIHandler(info=Info(title="Cats API", version="0.0.1"))
docs.ui_providers.append(ReDocUIProvider())
Expand Down
17 changes: 11 additions & 6 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@ entrypoints==0.3
essentials==1.1.4
essentials-openapi==0.1.4
flake8==3.7.9
guardpost~=0.0.9
Flask==2.0.2
gevent==21.12.0
guardpost==0.0.9
h11==0.11.0
httptools>=0.2,<0.4
h2==4.1.0
hpack==4.0.0
httptools==0.4.0
Hypercorn==0.11.2
hyperframe==6.0.1
idna==2.8
importlib-metadata==1.3.0
iniconfig==1.1.1
isort==5.9.3
itsdangerous==2.0.1
itsdangerous>=2.1.1
jedi==0.16.0
Jinja2==3.0.2
MarkupSafe==2.0.1
Expand Down Expand Up @@ -60,10 +61,14 @@ toml==0.10.1
tomli==1.2.1
typing-extensions==3.10.0.2
urllib3==1.26.5
uvicorn==0.15.0
uvicorn==0.17.6
wcwidth==0.1.7
websockets==10.1
websocket==0.2.1
websockets==10.2
Werkzeug==2.0.2
wsproto==1.0.0
wrapt==1.13.3
wsproto==1.1.0
zipp==0.6.0
uvloop==0.15.2; platform_system != "Windows"
zope.event==4.5.0
zope.interface==5.4.0
Loading

0 comments on commit 0fed9b3

Please sign in to comment.