Skip to content

Commit

Permalink
Feature/add pure middleware (#21)
Browse files Browse the repository at this point in the history
add raw context middleware
  • Loading branch information
tomwojcik committed Oct 9, 2020
1 parent c31b057 commit 4f6a397
Show file tree
Hide file tree
Showing 27 changed files with 388 additions and 202 deletions.
66 changes: 42 additions & 24 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,65 +1,76 @@
=========
Changelog
=========
==========
Change Log
==========

This document records all notable changes to starlette-context.
This project adheres to `Semantic Versioning <http://semver.org/>`_.

Latest release

*********
**0.2.3**
*********
--------
`0.2.4`_
--------
*Release date: to be released
* add `RawContextMiddleware` for `Streaming` and `File` responses

--------
`0.2.3`_
--------
*Release date: July 27, 2020*

* add docs on read the docs
* fix bug with ``force_new_uuid=True`` returning the same uuid constantly
* due to ^ a lot of tests had to be refactored as well

*********
**0.2.2**
*********
--------
`0.2.2`_
--------
*Release date: Apr 26, 2020*

* for correlation id and request id plugins, add support for enforcing the generation of a new value
* for ^ plugins add support for validating uuid. It's a default behavior so will break things for people who don't use uuid4 there. If you don't want this validation, you need to pass validate=False to the plugin
* thanks to @VukW you can now check if context is available

*********
**0.2.1**
*********
--------
`0.2.1`_
--------
*Release date: Apr 18, 2020*

* dropped with_plugins from the middleware as Starlette has it's own way of doing this
* due to ^ this change some tests are simplified
* if context is not available no LookupError will be raised, instead there will be RuntimeError, because this error might mean one of two things: user either didn't use ContextMiddleware or is trying to access context object outside of request-response cycle

*********
**0.2.0**
*********
--------
`0.2.0`_
--------
*Release date: Feb 21, 2020*

* changed parent of context object. More or less the API is the same but due to this change the implementation itself is way more simple and now it's possible to use .items() or keys() like in a normal dict, out of the box. Still, unpacking **kwargs is not supported and I don't think it ever will be. I tried to inherit from the builtin dict but nothing good came out of this. Now you access context as dict using context.data, not context.dict()
* there was an issue related to not having awaitable plugins. Now both middleware and plugins are fully async compatible. It's a breaking change as it forces to use await, hence new minor version
*********
**0.1.6**
*********
--------
`0.1.6`_
--------
*Release date: Jan 2, 2020*

* breaking changes
* one middleware, one context, multiple plugins for middleware
* very easy testing and writing custom plugins

*********
**0.1.5**
*********
--------
`0.1.5`_
--------
*Release date: Jan 1, 2020*

* lint
* tests (100% cov)
* separate class for header constants
* BasicContextMiddleware add some logic

*********
**0.1.4**
*********
--------
`0.1.4`_
--------
*Release date: Dec 31, 2019*

* get_many in context object
Expand All @@ -70,3 +81,10 @@ Latest release
**mvp until 0.1.4**
*******************
* experiments and tests with ContextVar

.. _0.1.5: https://github.com/tomwojcik/starlette-context/compare/0.1.4...0.1.5
.. _0.1.6: https://github.com/tomwojcik/starlette-context/compare/0.1.5...0.1.6
.. _0.2.0: https://github.com/tomwojcik/starlette-context/compare/0.1.6...0.2.0
.. _0.2.1: https://github.com/tomwojcik/starlette-context/compare/0.2.0...0.2.1
.. _0.2.2: https://github.com/tomwojcik/starlette-context/compare/0.2.1...0.2.2
.. _0.2.3: https://github.com/tomwojcik/starlette-context/compare/0.2.2...v0.2.3
39 changes: 37 additions & 2 deletions docs/source/middleware.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,30 @@
Middleware
==========

There's only one middleware (``ContextMiddleware``) as my idea was to extend its functionality using plugins.
There are two middlewares you can use. They achieve more or less the same thing.

Everything this middleware does can be seen in this single method but it's important to understand what is actually happening here.
``ContextMiddleware`` inherits from ``BaseHTTPMiddleware`` which is an interface prepared by ``encode``.
That is, in theory, the "normal" way of creating a middleware. It's simple and convenient.
However, if you are using StreamingResponse, you might bump into memory issues. See
- https://github.com/encode/starlette/issues/919
- https://github.com/encode/starlette/issues/1012

Authors `discourage the use of BaseHTTPMiddleware https://github.com/encode/starlette/issues/1012#issuecomment-673461832`_ in favor of what they call "raw middleware".
That's why I created a new one. It does more or less the same thing, but instead of creating the entire ``Request`` object,
only ``HTTPConnection`` is instantiated. That I think will be sufficient to mitigate this issue.

It is entirely possible that ``ContextMiddleware`` will be removed in the future release. Therefore, if possible, use only ``RawContextMiddleware``.

.. warning::

The `enrich_response` method won't run for unhandled exceptions.
Even if your tried to run it in your own 500 handler, the context won't be available in the handler as that's
how Starlette handles 500 (it's the last middleware to be run).
Therefore, at the current state of Starlette and this library, no response headers will be set for 500 responses either.

*****************
ContextMiddleware
*****************

.. code-block:: python
Expand Down Expand Up @@ -34,3 +55,17 @@ either write your own plugin or just overwrite the ``set_context`` method which
Then, once the response is created, we iterate over plugins so it's possible to set some response headers based on the context contents.

Finally, the "storage" that async python apps can access is removed.



********************
RawContextMiddleware
********************

Tries to achieve the same thing but differently. Here you can access only the request-like object you will instantiate yourself.
You can even instantiate the ``Request`` object but it's not recommended because it might cause memory issues as it tries to evaluate the payload.
All plugins so far need only access to headers. If you still need to access the ``Request`` object or do something custom, you might want to
overwrite the `get_request_object` method.

So, in theory, this middleware does the same thing. Should be faster and safer. But have in mind that some black magic is
involved over here and `I'm waiting for the documentation on this subject https://github.com/encode/starlette/issues/1029`_ to be improved.
1 change: 1 addition & 0 deletions examples/example_with_exception_handling/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR

import uvicorn

from examples.example_with_exception_handling.logger import log
from starlette_context import middleware, plugins

Expand Down
1 change: 1 addition & 0 deletions examples/example_with_logger/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from starlette.responses import JSONResponse

import uvicorn

from examples.example_with_logger.logger import log
from starlette_context import context, middleware, plugins

Expand Down
25 changes: 25 additions & 0 deletions examples/simple_examples/raw_context_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse

import uvicorn
from starlette_context import context, plugins
from starlette_context.middleware import RawContextMiddleware

middleware = [
Middleware(
RawContextMiddleware,
plugins=(plugins.RequestIdPlugin(), plugins.CorrelationIdPlugin()),
)
]

app = Starlette(debug=True, middleware=middleware)


@app.route("/")
async def index(request: Request):
return JSONResponse(context.data)


uvicorn.run(app, host="0.0.0.0")
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ def get_long_description():
keywords=["starlette", "fastapi"],
install_requires="starlette",
classifiers=[
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Build Tools",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
],
)
5 changes: 4 additions & 1 deletion starlette_context/header_keys.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
class HeaderKeys:
from enum import Enum


class HeaderKeys(str, Enum):
correlation_id = "X-Correlation-ID"
request_id = "X-Request-ID"
date = "Date"
Expand Down
2 changes: 2 additions & 0 deletions starlette_context/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .context_middleware import ContextMiddleware
from .raw_middleware import RawContextMiddleware
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class ContextMiddleware(BaseHTTPMiddleware):
"""
Middleware that creates empty context for request it's used on.
If not used, you won't be able to use context object.
Not to be used with StreamingResponse / FileResponse.
https://github.com/encode/starlette/issues/1012#issuecomment-673461832
"""

def __init__(
Expand Down
63 changes: 63 additions & 0 deletions starlette_context/middleware/raw_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from contextvars import Token
from typing import Optional, Sequence, Union

from starlette.requests import Request, HTTPConnection
from starlette.types import ASGIApp, Scope, Receive, Send, Message

from starlette_context import _request_scope_context_storage
from starlette_context.plugins import Plugin


class RawContextMiddleware:
def __init__(
self, app: ASGIApp, plugins: Optional[Sequence[Plugin]] = None
) -> None:
self.app = app
self.plugins = plugins or ()
if not all([isinstance(plugin, Plugin) for plugin in self.plugins]):
raise TypeError("This is not a valid instance of a plugin")

async def set_context(
self, request: Union[Request, HTTPConnection]
) -> dict:
"""
You might want to override this method.
The dict it returns will be saved in the scope of a context.
You can always do that later.
"""
return {
plugin.key: await plugin.process_request(request)
for plugin in self.plugins
}

@staticmethod
def get_request_object(
scope, receive, send
) -> Union[Request, HTTPConnection]:
# here we instantiate HTTPConnection instead of a Request object
# because using the latter one might cause some memory problems
# If you need the payload etc for your plugin instantiate Request(scope, receive, send)
return HTTPConnection(scope)

async def __call__(
self, scope: Scope, receive: Receive, send: Send
) -> None:
if scope["type"] not in ("http", "websocket"): # pragma: no cover
await self.app(scope, receive, send)
return

async def send_wrapper(message: Message) -> None:
for plugin in self.plugins:
await plugin.enrich_response(message)
await send(message)

request = self.get_request_object(scope, receive, send)

_starlette_context_token: Token = _request_scope_context_storage.set(
await self.set_context(request) # noqa
)

try:
await self.app(scope, receive, send_wrapper)
finally:
_request_scope_context_storage.reset(_starlette_context_token)
3 changes: 1 addition & 2 deletions starlette_context/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# flake8: noqa
from .correlation_id import CorrelationIdPlugin
from .date_header import DateHeaderPlugin
from .forwarded_for import ForwardedForPlugin
from .plugin import Plugin
from .request_id import RequestIdPlugin
from .user_agent import UserAgentPlugin
from .base import Plugin

0 comments on commit 4f6a397

Please sign in to comment.