starlette context

32
Starlette Context Release 0.3.3 Tomasz Wojcik Nov 30, 2021

Upload: others

Post on 02-Feb-2022

3 views

Category:

Documents


0 download

TRANSCRIPT

Starlette ContextRelease 0.3.3

Tomasz Wojcik

Nov 30, 2021

CONTENTS:

1 Quickstart 11.1 Installation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11.2 How to use . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1

2 Context object 3

3 Middleware 53.1 What for . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53.2 Errors and Middlewares in Starlette . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53.3 Why are there two middlewares that do the same thing . . . . . . . . . . . . . . . . . . . . . . . . . 63.4 ContextMiddleware . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63.5 RawContextMiddleware . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6

4 Plugins 9

5 Using a plugin 115.1 Example usage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11

6 Built-in plugins 136.1 UUID Plugins . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13

7 Implementing your own 157.1 Easy mode . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157.2 Intermediate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15

8 Errors 178.1 ContextDoesNotExistError . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17

9 Example 19

10 Contributing 2110.1 With docker . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2110.2 Local setup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21

11 License 23

12 Change Log 2512.1 0.3.3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2512.2 0.3.2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2512.3 0.3.1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2512.4 0.3.0 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2612.5 0.2.3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26

i

12.6 0.2.2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2612.7 0.2.1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2612.8 0.2.0 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2712.9 0.1.6 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2712.10 0.1.5 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2712.11 0.1.4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27

ii

CHAPTER

ONE

QUICKSTART

1.1 Installation

The only dependency for this project is Starlette, therefore this library should work with all Starlette-based frameworks,such as Responder, FastAPI or Flama.

$ pip install starlette-context

1.2 How to use

You can access the magic context if and only if all those conditions are met:

• you access it within a request-response cycle

• you used a ContextMiddleware or RawContextMiddleware in your ASGI app

Minimal working example

# app.py

from starlette.middleware import Middlewarefrom starlette.applications import Starlette

from starlette_context.middleware import RawContextMiddleware

middleware = [Middleware(RawContextMiddleware)]app = Starlette(middleware=middleware)

# views.py

from starlette.requests import Requestfrom starlette.responses import JSONResponse

from starlette_context import context

from .app import app

@app.route("/")async def index(request: Request):

return JSONResponse(context.data)

1

Starlette Context, Release 0.3.3

2 Chapter 1. Quickstart

CHAPTER

TWO

CONTEXT OBJECT

The context object is kept in ContextVar created for the request that is being processed asynchronously. ThisContextVar is a python object that has been introduced with 3.7. For more info go see the official docs of con-textvars.

Warning: If you see ContextDoesNotExistError please see Errors.

My idea was to create something like a g object in Flask.

In Django I think there’s no builtin similar solution but it can be compared to anything that allows you to store somedata in the thread such as django-currentuser or django-crum.

I wanted the interface to be as pythonic as possible so it mimics a dict. Most significant difference with dictis that you can’t unpack or serialize the context object itself. You’d have to use **context.data and json.dumps(context.data) accordingly, as .data returns a dict. Following operations work as expected

• context["key"]

• context.get("key")

• context.items()

• context["key"] = "value"

It will be available during the request-response cycle only if instantiated in the Starlette app with one of the middle-wares.

3

Starlette Context, Release 0.3.3

4 Chapter 2. Context object

CHAPTER

THREE

MIDDLEWARE

3.1 What for

The middleware effectively creates the context for the request, so you must configure your app to use it. More usagedetail along with code examples can be found in Plugins.

3.2 Errors and Middlewares in Starlette

There may be a validation error occuring while processing the request in the plugins, which requires sending an errorresponse. Starlette however does not let middleware use the regular error handler (more details), so middlewares facinga validation error have to send a response by themselves.

By default, the response sent will be a 400 with no body or extra header, as a StarletteResponse(status_code=400). This response can be customized at both middleware and plugin level.

The middlewares accepts a Response object (or anything that inherits it, such as a JSONResponse)through default_error_response keyword argument at init. This response will be sent on raisedstarlette_context.errors.MiddleWareValidationError exceptions, if it doesn’t include a responseitself.

middleware = [Middleware(

ContextMiddleware,default_error_response=JSONResponse(

status_code=status.HTTP_422_UNPROCESSABLE_ENTITYcontent={"Error": "Invalid request"},

),# plugins = ...

)]

5

Starlette Context, Release 0.3.3

3.3 Why are there two middlewares that do the same thing

ContextMiddleware inherits from BaseHTTPMiddleware which is an interface prepared by encode. Thatis, in theory, the “normal” way of creating a middleware. It’s simple and convenient. However, if you are usingStreamingResponse, you might bump into memory issues. See

• https://github.com/encode/starlette/issues/919

• https://github.com/encode/starlette/issues/1012

Authors recently started to discourage the use of BaseHTTPMiddleware in favor of what they call “raw middleware”.The problem with the “raw” one is that there’s no docs for how to actually create it.

The RawContextMiddleware does more or less the same thing. It is entirely possible thatContextMiddleware will be removed in the future release. It is also possible that authors will make some changesto the BaseHTTPMiddleware to fix this issue. I’d advise to only use RawContextMiddleware.

Warning: Due to how Starlette handles application exceptions, the enrich_response method won’t run, andthe default error response will not be used after an unhandled exception.

Therefore, this middleware is not capable of setting response headers for 500 responses. You can try to use yourown 500 handler, but beware that the context will not be available.

3.4 ContextMiddleware

Firstly we create a “storage” for the context. The set_context method allows us to assign something to the contexton creation therefore that’s the best place to add everything that might come in handy later on. You can always alterthe context, so add/remove items from it, but each operation comes with some cost.

All plugins are executed when set_context method is called. If you want to add something else there youmight either write your own plugin or just overwrite the set_context method which returns a dict. Just addanything you need to it before you return it.

Then, once the response is created, we iterate over plugins so it’s possible to set some response headers based on thecontext contents.

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

3.5 RawContextMiddleware

Excerpt

@staticmethoddef get_request_object(

scope, receive, send) -> Union[Request, HTTPConnection]:

# here we instantiate HTTPConnection instead of a Request object# because only headers are needed so that's sufficient.# If you need the payload etc for your plugin# instantiate Request(scope, receive, send)return HTTPConnection(scope)

async def __call__(

(continues on next page)

6 Chapter 3. Middleware

Starlette Context, Release 0.3.3

(continued from previous page)

self, scope: Scope, receive: Receive, send: Send) -> None:

if scope["type"] not in ("http", "websocket"): # pragma: no coverawait 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)

Tries to achieve the same thing but differently. Here you can access only the request-like object you will instantiateyourself. You might want to instantiate the Request object but HTTPConnection seems to be the interface thatis needed as it gives us an access to the headers. If you need to evaluate payload in the middleware, return Requestobject from the get_request_object instead.

So, in theory, this middleware does the same thing. Should be faster and safer. But have in mind that some blackmagic is involved here and I’m waiting for the documentation on this subject to be improved.

3.5. RawContextMiddleware 7

Starlette Context, Release 0.3.3

8 Chapter 3. Middleware

CHAPTER

FOUR

PLUGINS

Context plugins allow you to extract any data you want from the request and store it in the context object. I wroteplugins for the most common use cases that come to my mind, like extracting Correlation ID. You can extend thebuilt-in plugins and/or implement your own too.

9

Starlette Context, Release 0.3.3

10 Chapter 4. Plugins

CHAPTER

FIVE

USING A PLUGIN

You may add as many plugins as you want to your middleware. You pass them to the middleware accordingly to theStarlette standard.

There may be a validation error occuring while processing the request in the plugins, which requires sending an errorresponse. Starlette however does not let middleware use the regular error handler (more details on this), so middlewaresfacing a validation error have to send a response by themselves.

By default, the response sent will be a 400 with no body or extra header, as a Starlette Response(status_code=400).This response can be customized at both middleware and plugin level.

5.1 Example usage

from starlette.applications import Starlettefrom starlette.middleware import Middlewarefrom starlette_context import pluginsfrom starlette_context.middleware import ContextMiddleware

middleware = [Middleware(

ContextMiddleware,plugins=(

plugins.RequestIdPlugin(),plugins.CorrelationIdPlugin()

))

]

app = Starlette(middleware=middleware)

You can use the middleware without plugin, it will only create the context for the request and not populate it directly.

11

Starlette Context, Release 0.3.3

12 Chapter 5. Using a plugin

CHAPTER

SIX

BUILT-IN PLUGINS

starlette_context includes the following plugins you can import and use as shown above. They are all accessible fromthe plugins module.

Do note headers are case-insentive. You can access the header value through the <plugin class>.key attribute, orthrough the starlette_context.header_keys.HeaderKeys enum.

Plugin Class Name Extracted Header NotesAPI Key ApiKeyPlugin X-API-KeyCorrelation ID CorrelationIdPlugin X-Correlation-ID UUID PluginDate Header DateHeaderPlugin Date Keeps it in context as a datetimeForwarded For ForwardedForPlugin X-Forwarded-ForRequest ID RequestIdPlugin X-Request-ID UUID PluginUser Agent UserAgentPlugin User-Agent

6.1 UUID Plugins

UUID plugins accept force_new_uuid=True to enforce the creation of a new UUID. Defaults to False.

If the target header has a value, it is validated to be a UUID (although kept as str in the context). The error response ifthis validation fails can be customized with error_response=<Response object>. If no error response wasspecified, the middleware’s default response will be used. This validation can be turned off altogether with validate= False.

13

Starlette Context, Release 0.3.3

14 Chapter 6. Built-in plugins

CHAPTER

SEVEN

IMPLEMENTING YOUR OWN

You can implement your plugin with variying degree of ease and flexibility.

7.1 Easy mode

You want a Plugin to extract a header that is not already available in the built-in ones. There are indeed many, andyour app may even want to use a custom header.

You just need to define the header key that you’re looking for.

from starlette_context.plugins import Plugin

class AcceptLanguagePlugin(Plugin):key = "Accept-Language"

That’s it! Just load it in your Middleware’s plugins, and the value of the Accept-Language header will be putin the context, which you can later get with context.get(AcceptLanguagePlugin.key) or context.get("Accept-Language") Hopefully you can use it to try and serve locally appropriate content.

You can notice the key attributes is both used to define the header you want to extract data from, and the key withwhich it is inserted in the context.

7.2 Intermediate

What if you don’t want to put the header’s value as a plain str, or don’t even want to take data from the header?

You need to override the process_request method. This gives you full access to the request, freedom to performany processing in-between, and to return any value type. Whatever is returned will be put in the context, again withthe plugin’s defined key.

Any Exception raised from a middleware in Starlette would normally become a hard 500 response. However youprobably might find cases where you want to send a validation error instead. For those cases, starlette_contextprovides a MiddleWareValidationError exception you can raise, and include a Starlette Response object.The middleware class will take care of sending it. You can also raise a MiddleWareValidationError without attachinga response, the middleware’s default response will then be used.

You can also do more than extracting from requests, plugins also have a hook to modify the response before it’s sent:enrich_response. It can access the Response object, and of course, the context, fully populated by that point.

Here an example of a plugin that extracts a Session from the request cookies, expects it to be encoded in base64,attempts to decode it before returning it to the context. It generates an error response if it cannot be decoded. On theway out, it retrieves the value it put in the context, and sets a new cookie.

15

Starlette Context, Release 0.3.3

import base64import loggingfrom typing import Any, Optional, Union

from starlette.responses import Responsefrom starlette.requests import HTTPConnection, Requestfrom starlette.types import Message

from starlette_context.plugins import Pluginfrom starlette_context.errors import MiddleWareValidationErrorfrom starlette_context import context

class MySessionPlugin(Plugin):# The returned value will be inserted in the context with this keykey = "session_cookie"

async def process_request(self, request: Union[Request, HTTPConnection]

) -> Optional[Any]:# access any part of the requestraw_cookie = request.cookies.get("Session")if not raw_cookie:

# it will be inserted as None in the context.return None

try:decoded_cookie = base64.b64decode(bytes(raw_cookie, encoding="utf-8"))

except Exception as e:logging.error("Raw cookie couldn't be decoded", exc_info=e)# create a response to signal the user of the invalid cookie.response = Response(

content=f"Invalid cookie: {raw_cookie}", status_code=400)# pass the response object in the exception so the middleware can abort

→˓processing and send it.raise MiddleWareValidationError("Cookie problem", error_response=response)

return decoded_cookie

async def enrich_response(self, response: Union[Response, Message]) -> None:# can access the populated context here.previous_cookie = context.get("session_cookie")response.set_cookie("PreviousSession", previous_cookie)response.set_cookie("Session", "SGVsbG8gV29ybGQ=")# mutate the response in-place, return nothing.

Do note, the type of request and response argument received depends on the middlewares class used. The exampleshown here is valid for use with the ContextMiddleware, receiving built Starlette Request and Responseobjects. In a RawContextMiddleware, the hooks will receive HTTPConnection and Message objects passedas argument.

16 Chapter 7. Implementing your own

CHAPTER

EIGHT

ERRORS

8.1 ContextDoesNotExistError

You will see this error whenever you try to access context object outside of the request-response cycle. To be morespecific:

1. ContextVar store not created.

RawContextMiddleware uses ContextVar to create a storage that will be available within the request-responsecycle. So you will see the error if you are trying to access this object before using RawContextMiddleware (fe.in another middleware), which instantiates ContextVar which belongs to event in the event loop.

2. Wrong order of middlewares

class FirstMiddleware(BaseHTTPMiddleware): pass # can't access context

class SecondMiddleware(RawContextMiddleware): pass # creates a context and can add→˓into it

class ThirdContextMiddleware(BaseHTTPMiddleware): pass # can access context

middlewares = [Middleware(FirstMiddleware),Middleware(SecondMiddleware),Middleware(ThirdContextMiddleware),

]

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

As stated in the point no. 1, the order of middlewares matters. If you want to read more into order of execution ofmiddlewares, have a look at #479.

Note, contents of this context object are gone when response pass SecondMiddleware in this example.

3. Outside of the request-response cycle.

Depending on how you setup your logging, it’s possible that your server (uvicorn) or other 3rd party loggerssometimes will be able to access context, sometimes not. You might want to check if context.exists() tolog it only if it’s available.

It also applies to your tests. You can’t send a request, get the response and then check what’s in the context object.For that you’d either have to use some ugly mocking or return the context in the response as dict.

17

Starlette Context, Release 0.3.3

18 Chapter 8. Errors

CHAPTER

NINE

EXAMPLE

Runnable example can be found under example in repo.

import structlogfrom starlette.applications import Starlettefrom starlette.middleware import Middlewarefrom starlette.middleware.base import (

BaseHTTPMiddleware,RequestResponseEndpoint,

)from starlette.requests import Requestfrom starlette.responses import JSONResponse, Response

from starlette_context import context, pluginsfrom starlette_context.middleware import RawContextMiddleware

logger = structlog.get_logger("starlette_context_example")

class LoggingMiddleware(BaseHTTPMiddleware):"""Example logging middleware."""

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint

) -> Response:await logger.info("request log", request=request)response = await call_next(request)await logger.info("response log", response=response)return response

middlewares = [Middleware(

RawContextMiddleware,plugins=(

plugins.CorrelationIdPlugin(),plugins.RequestIdPlugin(),

),),Middleware(LoggingMiddleware),

]

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

(continues on next page)

19

Starlette Context, Release 0.3.3

(continued from previous page)

@app.on_event("startup")async def startup_event() -> None:

from setup_logging import setup_logging

setup_logging()

@app.route("/")async def index(request: Request):

context["something else"] = "This will be visible even in the response log"await logger.info("log from view")return JSONResponse(context.data)

20 Chapter 9. Example

CHAPTER

TEN

CONTRIBUTING

I’m very happy with all the tickets you open. Feel free to open PRs if you feel like it. If you’ve found a bug but don’twant to get involved, that’s more than ok and I’d appreciate such ticket as well.

If you have opened a PR it can’t be merged until CI passed. Stuff that is checked:

• codecov has to be kept at 100%

• pre commit hooks consist of flake8 and mypy, so consider installing hooks before commiting. OtherwiseCI might fail

Sometimes one pre-commit hook will affect another so you will run them a few times.

You can run tests with docker of with venv.

10.1 With docker

With docker run tests with make testdocker. If you want to plug docker env in your IDE run service testsfrom docker-compose.yml.

10.2 Local setup

Running make init will result with creating local venv with dependencies. Then you can make test or plugvenv into IDE.

21

Starlette Context, Release 0.3.3

22 Chapter 10. Contributing

CHAPTER

ELEVEN

LICENSE

MIT License

Copyright (c) 2021 Tomasz Wójcik

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documen-tation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use,copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whomthe Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of theSoftware.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PAR-TICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHTHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTIONOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFT-WARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

23

Starlette Context, Release 0.3.3

24 Chapter 11. License

CHAPTER

TWELVE

CHANGE LOG

This document records all notable changes to starlette-context. This project adheres to Semantic Versioning.

Latest release

12.1 0.3.3

Release date: June 28, 2021

• add support for custom error responses if error occurred in plugin / middleware -> fix for 500 (Thanks@hhamana)

• better (custom) exceptions with a base StarletteContextError (Thanks @hhamana)

12.2 0.3.2

Release date: April 22, 2021

• ContextDoesNotExistError is raised when context object can’t be accessed. Previously it wasRuntimeError.

For backwards compatibility, it inherits from RuntimeError so it shouldn’t result in any regressions. * Addedpy.typed file so your mypy should never complain (Thanks @ginomempin)

12.3 0.3.1

Release date: October 17, 2020

• add ApiKeyPlugin plugin for X-API-Key header

25

Starlette Context, Release 0.3.3

12.4 0.3.0

Release date: October 10, 2020

• add RawContextMiddleware for Streaming and File responses

• add flake8, isort, mypy

• small refactor of the base plugin, moved directories and removed one redundant method (potentially breakingchanges)

12.5 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

12.6 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’tuse uuid4 there. If you don’t want this validation, you need to pass validate=False to the plugin

• you can now check if context is available (Thanks @VukW)

12.7 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 errormight mean one of two things: user either didn’t use ContextMiddleware or is trying to access context objectoutside of request-response cycle

26 Chapter 12. Change Log

Starlette Context, Release 0.3.3

12.8 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 implementationitself 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 dictbut 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 asynccompatible. It’s a breaking change as it forces to use await, hence new minor version

12.9 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

12.10 0.1.5

Release date: Jan 1, 2020

• lint

• tests (100% cov)

• separate class for header constants

• BasicContextMiddleware add some logic

12.11 0.1.4

Release date: Dec 31, 2019

• get_many in context object

• cicd improvements

• type annotations

12.8. 0.2.0 27

Starlette Context, Release 0.3.3

12.11.1 mvp until 0.1.4

• experiments and tests with ContextVar

28 Chapter 12. Change Log