From 61d30dca23d1ce9383066c846f46134a4f03457b Mon Sep 17 00:00:00 2001 From: Joe Kaufeld Date: Tue, 15 Oct 2024 15:00:56 -0400 Subject: [PATCH] :sparkles: add post_process hook for middleware --- docs/middleware/custom_middleware.md | 59 ++++++++++++++++++++++++++-- example.py | 2 + example_middleware.py | 25 ++++++++++++ spiderweb/main.py | 9 +++-- spiderweb/middleware/__init__.py | 11 ++++++ spiderweb/middleware/base.py | 18 ++++++++- spiderweb/tests/middleware.py | 5 +++ spiderweb/tests/test_middleware.py | 16 ++++++++ 8 files changed, 136 insertions(+), 9 deletions(-) diff --git a/docs/middleware/custom_middleware.md b/docs/middleware/custom_middleware.md index 26717a9..82ce0b9 100644 --- a/docs/middleware/custom_middleware.md +++ b/docs/middleware/custom_middleware.md @@ -26,7 +26,7 @@ class TestMiddleware(SpiderwebMiddleware): Middleware is run twice: once for the incoming request and once for the outgoing response. You only need to include whichever function is required for the functionality you need. -## process_request(self, request): +## process_request(self, request: Request) -> Optional[HttpResponse]: `process_request` is called before the view is reached in the execution order. You will receive the assembled Request object, and any middleware declared above this one will have already run. Because the request is the single instantiation of a class, you can modify it in-place without returning anything and your changes will stick. @@ -45,16 +45,67 @@ class JohnMiddleware(SpiderwebMiddleware): In this case, if the user John tries to access any route that starts with "/admin", he'll immediately get denied and the view will never be called. If the request does not have a user attached to it (or the user is not John), then the middleware will return None and Spiderweb will continue processing. -## process_response(self, request, response): +## process_response(self, request: Request, response: HttpResponse) -> None: This function is called after the view has run and returned a response. You will receive the request object and the response object; like with the request object, the response is also a single instantiation of a class, so any changes you make will stick automatically. Unlike `process_request`, returning a value here doesn't change anything. We're already processing a request, and there are opportunities to turn away requests / change the response at both the `process_request` layer and the view layer, so Spiderweb assumes that whatever it is working on here is what you mean to return to the user. The response object that you receive in the middleware is still prerendered, so any changes you make to it will take effect after it finishes the middleware and renders the response. -## on_error(self, request, triggered_exception): +## on_error(self, request: Request, triggered_exception: Exception) -> Optional[HttpResponse]: This is a helper function that is available for you to override; it's not often used by middleware, but there are some ([like the pydantic middleware](pydantic.md)) that call `on_error` when there is a validation failure. +## post_process(self, request: Request, rendered_response: str) -> str: + +> New in 1.3.0! + +After `process_request` and `process_response` run, the response is rendered out into the raw text that is going to be sent to the client. Right before that happens, `post_process` is called on each middleware in the same order as `process_response` (so the closer something is to the beginning of the middleware list, the more important it is). + +Note that this function *must* return something. Each invocation of `post_process` overwrites the entire output of the response, so make sure to return everything that you want to send. For example, here's a middleware that ~~breaks~~ adjusts the capitalization of the response and also demonstrates passing variables into the middleware: + +```python +import random + +from spiderweb.request import Request +from spiderweb.middleware import SpiderwebMiddleware +from spiderweb.exceptions import ConfigError + + +class CaseTransformMiddleware(SpiderwebMiddleware): + # this breaks everything, but it's hilarious so it's worth it. + # Blame Sam. + def post_process(self, request: Request, rendered_response: str) -> str: + valid_options = ["spongebob", "random"] + # grab the value from the extra data passed into the server object + # during instantiation + method = self.server.extra_data.get("case_transform_middleware_type", "spongebob") + if method not in valid_options: + raise ConfigError( + f"Invalid method '{method}' for CaseTransformMiddleware." + f" Valid options are {', '.join(valid_options)}" + ) + + if method == "spongebob": + return "".join( + char.upper() + if i % 2 == 0 + else char.lower() for i, char in enumerate(rendered_response) + ) + else: + return "".join( + char.upper() + if random.random() > 0.5 + else char for char in rendered_response + ) + +# usage: + +app = SpiderwebRouter( + middleware=["CaseTransformMiddleware"], + case_transform_middleware_type="random", +) +``` + ## checks If you want to have runtime verifications that ensure that everything is running smoothly, you can take advantage of Spiderweb's `checks` feature. @@ -109,4 +160,4 @@ List as many checks as you need there, and the server will run all of them durin from spiderweb.exceptions import UnusedMiddleware ``` -If you don't want your middleware to run for some reason, either `process_request` or `process_response` can raise the UnusedMiddleware exception. If this happens, Spiderweb will kick your middleware out of the processing order for the rest of the life of the server. Note that this applies to the middleware as a whole, so both functions will not be run if an UnusedMiddleware is raised. This is a great way to mark debug middleware that shouldn't run or create time-delay middleware that runs until a certain condition is met! +If you don't want your middleware to run for some reason, `process_request`, `process_response` and `post_process` can all raise the UnusedMiddleware exception. If this happens, Spiderweb will kick your middleware out of the processing order for the rest of the life of the server. Note that this applies to the middleware as a whole, so all functions in the middleware will not be run if an UnusedMiddleware is raised. This is a great way to mark debug middleware that shouldn't run or create time-delay middleware that runs until a certain condition is met! diff --git a/example.py b/example.py index 8a315bc..72a0cd2 100644 --- a/example.py +++ b/example.py @@ -22,12 +22,14 @@ app = SpiderwebRouter( "example_middleware.RedirectMiddleware", "spiderweb.middleware.pydantic.PydanticMiddleware", "example_middleware.ExplodingMiddleware", + # "example_middleware.CaseTransformMiddleware", ], staticfiles_dirs=["static_files"], append_slash=False, # default cors_allow_all_origins=True, static_url="static_stuff", debug=True, + case_transform_middleware_type="spongebob", ) diff --git a/example_middleware.py b/example_middleware.py index 4d30a3d..b69ed2b 100644 --- a/example_middleware.py +++ b/example_middleware.py @@ -1,3 +1,6 @@ +import random + +from spiderweb import ConfigError from spiderweb.exceptions import UnusedMiddleware from spiderweb.middleware import SpiderwebMiddleware from spiderweb.request import Request @@ -24,3 +27,25 @@ class RedirectMiddleware(SpiderwebMiddleware): class ExplodingMiddleware(SpiderwebMiddleware): def process_request(self, request: Request) -> HttpResponse | None: raise UnusedMiddleware("Unfinished!") + + +class CaseTransformMiddleware(SpiderwebMiddleware): + # this breaks everything, but it's hilarious so it's worth it. + # Blame Sam. + def post_process(self, request: Request, rendered_response: str) -> str: + valid_options = ["spongebob", "random"] + method = self.server.extra_data.get("case_transform_middleware_type", "spongebob") + if method not in valid_options: + raise ConfigError( + f"Invalid method '{method}' for CaseTransformMiddleware." + f" Valid options are {', '.join(valid_options)}" + ) + + if method == "spongebob": + return "".join( + char.upper() if i % 2 == 0 else char.lower() for i, char in enumerate(rendered_response) + ) + else: + return "".join( + char.upper() if random.random() > 0.5 else char for char in rendered_response + ) diff --git a/spiderweb/main.py b/spiderweb/main.py index b5a935c..e30bb1d 100644 --- a/spiderweb/main.py +++ b/spiderweb/main.py @@ -221,18 +221,19 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi start_response(status, headers) try: - rendered_output = resp.render() + rendered_output: str = resp.render() + final_output: str | list[str] = self.post_process_middleware(request, rendered_output) except Exception as e: self.log.error("Fatal error!") self.log.error(e) self.log.error(traceback.format_exc()) return [f"Internal Server Error: {e}".encode(DEFAULT_ENCODING)] - if not isinstance(rendered_output, list): - rendered_output = [rendered_output] + if not isinstance(final_output, list): + final_output = [final_output] encoded_resp = [ chunk.encode(DEFAULT_ENCODING) if isinstance(chunk, str) else chunk - for chunk in rendered_output + for chunk in final_output ] return encoded_resp diff --git a/spiderweb/middleware/__init__.py b/spiderweb/middleware/__init__.py index 3bcfce2..ad4757b 100644 --- a/spiderweb/middleware/__init__.py +++ b/spiderweb/middleware/__init__.py @@ -60,3 +60,14 @@ class MiddlewareMixin: except UnusedMiddleware: self.middleware.remove(middleware) continue + + def post_process_middleware(self, request: Request, response: str) -> str: + # run them in reverse order, same as process_response. The top of the middleware + # stack should be the first and last middleware to run. + for middleware in reversed(self.middleware): + try: + response = middleware.post_process(request, response) + except UnusedMiddleware: + self.middleware.remove(middleware) + continue + return response diff --git a/spiderweb/middleware/base.py b/spiderweb/middleware/base.py index 9a68e3e..90a52a1 100644 --- a/spiderweb/middleware/base.py +++ b/spiderweb/middleware/base.py @@ -9,6 +9,8 @@ class SpiderwebMiddleware: process_request(self, request) -> None or Response process_response(self, request, resp) -> None + on_error(self, request, e) -> Response + post_process(self, request, resp) -> Response Middleware can be used to modify requests and responses in a variety of ways. If one of the two methods is not defined, the request or resp will be passed @@ -22,12 +24,26 @@ class SpiderwebMiddleware: self.server = server def process_request(self, request: Request) -> HttpResponse | None: + # This method is called before the request is passed to the view. You can safely + # modify the request in this method, or return an HttpResponse to short-circuit + # the request and return a response immediately. pass def process_response( self, request: Request, response: HttpResponse - ) -> HttpResponse | None: + ) -> None: + # This method is called after the view has returned a response. You can modify + # the response in this method. The response will be returned to the client after + # all middleware has been processed. pass def on_error(self, request: Request, e: Exception) -> HttpResponse | None: + # This method is called if an exception is raised during the request. You can + # return a response here to handle the error. If you return None, the exception + # will be re-raised. pass + + def post_process(self, request: Request, rendered_response: str) -> str: + # This method is called after all the middleware has been processed and receives + # the final rendered response in str form. You can modify the response here. + return rendered_response diff --git a/spiderweb/tests/middleware.py b/spiderweb/tests/middleware.py index a9950a1..9517b89 100644 --- a/spiderweb/tests/middleware.py +++ b/spiderweb/tests/middleware.py @@ -16,3 +16,8 @@ class ExplodingResponseMiddleware(SpiderwebMiddleware): class InterruptingMiddleware(SpiderwebMiddleware): def process_request(self, request: Request) -> HttpResponse: return HttpResponse("Moo!") + + +class PostProcessingMiddleware(SpiderwebMiddleware): + def post_process(self, request: Request, response: str) -> str: + return response + " Moo!" diff --git a/spiderweb/tests/test_middleware.py b/spiderweb/tests/test_middleware.py index 7b49c46..75947c7 100644 --- a/spiderweb/tests/test_middleware.py +++ b/spiderweb/tests/test_middleware.py @@ -298,6 +298,22 @@ def test_csrf_trusted_origins(): assert resp2 == '{"name": "bob"}' +def test_post_process_middleware(): + app, environ, start_response = setup( + middleware=[ + "spiderweb.tests.middleware.PostProcessingMiddleware", + ], + ) + + app.add_route("/", text_view) + + environ["HTTP_USER_AGENT"] = "hi" + environ["REMOTE_ADDR"] = "/" + environ["REQUEST_METHOD"] = "GET" + + assert app(environ, start_response) == [bytes("Hi! Moo!", DEFAULT_ENCODING)] + + class TestCorsMiddleware: # adapted from: # https://github.com/adamchainz/django-cors-headers/blob/main/tests/test_middleware.py