Compare commits

...

2 Commits

Author SHA1 Message Date
9a407495f8 🔖 release 1.3.0 2024-10-15 15:01:10 -04:00
61d30dca23 add post_process hook for middleware 2024-10-15 15:00:56 -04:00
10 changed files with 138 additions and 11 deletions

View File

@ -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!

View File

@ -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",
)

View File

@ -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
)

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "spiderweb-framework"
version = "1.2.1"
version = "1.3.0"
description = "A small web framework, just big enough for a spider."
authors = ["Joe Kaufeld <opensource@joekaufeld.com>"]
readme = "README.md"

View File

@ -2,7 +2,7 @@ from peewee import DatabaseProxy
DEFAULT_ALLOWED_METHODS = ["POST", "GET", "PUT", "PATCH", "DELETE"]
DEFAULT_ENCODING = "UTF-8"
__version__ = "1.2.1"
__version__ = "1.3.0"
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
REGEX_COOKIE_NAME = r"^[a-zA-Z0-9\s\(\)<>@,;:\/\\\[\]\?=\{\}\"\t]*$"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!"

View File

@ -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