Last active
November 21, 2022 21:12
-
-
Save pirate/79f84dfee81ba0a38b6113541e827fd5 to your computer and use it in GitHub Desktop.
An extended HTTPResponse class for Django 2.2 adding support for streaming partial template responses incrementally, preload headers, HTTP2 server push, CSP headers, running post-request callbacks, and more (fully typed).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
This is an extended HTTP response class for Django >=2.0 that adds lots of fancy features. | |
It's most useful when needing to accellerate slow view functions that return templates, | |
but it can be used anywhere where you need to return an HTTPResponse() or render(...). | |
It works by subclassing the builtin Django HttpResponse and StreamingHttpResponse, | |
and adding improvements in many areas, including functionality, speed, and security. | |
The most up-to-date version of this code can be found here: | |
https://gist.github.com/pirate/79f84dfee81ba0a38b6113541e827fd5 | |
Features: | |
- can stream response fragments incrementally from a generator in the view | |
- pre-sending response headers before the view code starts executing (can speed up pageloads dramatically) | |
- splitting up templates into a head, body, and footer, and sending the head section before the view starts (can speed up pageloads dramatically) | |
- adding HTTP preload headers (using django-http2-middleware) | |
- enabling HTTP2 server-push (using django-http2-middleware) | |
- automatically including any needed CSP headers with nonces (using django-csp) | |
- running async callbacks after the response is sent to the user without using signals or needing jobs systems like Celery/dramatiq | |
Dependencies: | |
- (required) django-http2-middleware https://github.com/pirate/django-http2-middleware | |
- (optional) django-csp https://github.com/mozilla/django-csp | |
Usage: | |
class ExampleView(View): | |
def get(self, request): | |
def render_body(): | |
yield '<span> some html here</span>' | |
return TurboStreamingResponse(request, render_body) | |
Further reading: | |
- https://github.com/pirate/django-http2-middleware | |
- https://docs.djangoproject.com/en/2.2/ref/request-response/#django.http.StreamingHttpResponse | |
- https://andrewbrookins.com/django/how-does-djangos-streaminghttpresponse-work-exactly/ | |
- https://dexecure.com/blog/http2-push-vs-http-preload | |
- https://github.com/mozilla/django-csp | |
""" | |
import sys | |
from typing import Dict, List, Iterable, Optional, Callable, Any | |
from http2 import create_preload_header # see https://github.com/pirate/django-http2-middleware | |
from django.conf import settings | |
from django.urls import resolve | |
from django.http import HttpRequest, HttpResponse, StreamingHttpResponse | |
from django.template.loader import render_to_string | |
from django.contrib.auth.models import User | |
### Types | |
class RequestType(HttpRequest): | |
user: User | |
ResponseType = HttpResponse | |
HeadersType = Dict[str, str] | |
PreloadsType = List[str] | |
ContextType = Dict[str, Any] | |
RenderContent = Iterable[str] | |
RenderFunction = Callable[[], RenderContent] | |
CallbackFunction = Callable[[ResponseType], None] | |
def current_function_name(above=1): | |
"""returns the calling function's __module__.__name__""" | |
# https://gist.github.com/JettJones/c236494013f22723c1822126df944b12#gistcomment-2962311 | |
frame = sys._getframe() | |
for frame_idx in range(0, above): | |
frame = frame.f_back | |
caller_module = frame.f_globals["__name__"] | |
caller_name = frame.co_code.co_name | |
return f'{caller_module}.{caller_name}' | |
### Base Classes | |
class BaseTurboResponse(ResponseType): | |
# Instance Attribute Types | |
_request: RequestType | |
_render: RenderFunction | |
_callback: CallbackFunction | |
_headers: HeadersType | |
_preloads: PreloadsType | |
_enable_preload: bool | |
_enable_push: bool | |
def __init__(self, | |
request: RequestType, | |
render: RenderFunction, | |
headers: Optional[HeadersType]= None, | |
preloads: Optional[PreloadsType]=None, | |
callback: Optional[CallbackFunction]=None, | |
enable_preload: bool=getattr(settings, 'HTTP2_PRELOAD_HEADERS', True), | |
enable_push: bool=getattr(settings, 'HTTP2_SERVER_PUSH', True), | |
**kwargs): | |
self._request = request | |
self._response = render # type: ignore | |
self._headers = headers or {} | |
self._preloads = preloads or [] | |
self._callback = callback or (lambda _: None) # type: ignore | |
self._enable_preload = enable_preload | |
self._enable_push = enable_push | |
super().__init__(render(), **kwargs) | |
self._set_headers() | |
self._set_preloads() | |
def _set_preloads(self): | |
if self._preloads and self._enable_preload: | |
self['Link'] = create_preload_header( | |
urls=self._preloads, | |
csp_nonce=getattr(self._request, 'csp_nonce', None), | |
server_push=self._enable_push, | |
) | |
def _set_headers(self): | |
if self._headers: | |
for name, value in self._headers.items(): | |
self[name] = value | |
def close(self): | |
self._callback(response=self) | |
super().close() | |
class BaseTurboStreamingResponse(BaseTurboResponse, StreamingHttpResponse): | |
pass | |
### Main Classes | |
class TurboResponse(BaseTurboResponse): | |
BASE_HEADERS = getattr(settings, 'BASE_HEADERS', {}) | |
BASE_PRELOADS = getattr(settings, 'BASE_PRELOADS', {}) | |
BASE_CONTEXT = getattr(settings, 'BASE_CONTEXT', {}) | |
BASE_TEMPLATE_HEAD = getattr(settings, 'BASE_TEMPLATE_HEAD', None) | |
BASE_TEMPLATE_BODY = getattr(settings, 'BASE_TEMPLATE_BODY', None) | |
BASE_TEMPLATE_FOOT = getattr(settings, 'BASE_TEMPLATE_FOOT', None) | |
def __init__(self, | |
request: RequestType, | |
render_body: Optional[RenderFunction]=None, | |
render_head: Optional[RenderFunction]=None, | |
render_foot: Optional[RenderFunction]=None, | |
render: Optional[RenderFunction]=None, | |
head_template: Optional[str]=None, | |
body_template: Optional[str]=None, | |
foot_template: Optional[str]=None, | |
context: Optional[ContextType]=None, | |
extra_context: Optional[ContextType]=None, | |
headers: Optional[HeadersType]= None, | |
extra_headers: Optional[HeadersType]= None, | |
preloads: Optional[PreloadsType]=None, | |
extra_preloads: Optional[PreloadsType]=None, | |
callback: Optional[CallbackFunction]=None, | |
**kwargs): | |
self.BASE_HEADERS = {**self.BASE_HEADERS} | |
self.BASE_PRELOADS = {**self.BASE_PRELOADS} | |
self.BASE_CONTEXT = { | |
**self.BASE_CONTEXT, | |
'REQUEST_VIEW_NAME': current_function_name(2), | |
'CSP_NONCE': getattr(request, 'csp_nonce', None), | |
} | |
super().__init__( | |
request=request, | |
render=self._build_render( | |
request, | |
render, | |
render_head, | |
render_body, | |
render_foot, | |
head_template, | |
body_template, | |
foot_template, | |
context, | |
extra_context, | |
), | |
headers=self._build_headers(headers, extra_headers), | |
preloads=self._build_preloads(preloads, extra_preloads), | |
callback=callback, | |
**kwargs, | |
) | |
@classmethod | |
def get_context(cls, request): | |
return { | |
**cls.BASE_CONTEXT, | |
'REQUEST_VIEW_NAME': current_function_name(2), | |
'CSP_NONCE': getattr(request, 'csp_nonce', None), | |
} | |
def _build_headers(self, | |
headers: Optional[HeadersType], | |
extra_headers: Optional[HeadersType]) -> HeadersType: | |
extra_headers = extra_headers or {} | |
if headers is not None: | |
return {**headers, **extra_headers} | |
return {**self.BASE_HEADERS, **extra_headers} | |
def _build_preloads(self, | |
preloads: Optional[PreloadsType], | |
extra_preloads: Optional[PreloadsType]) -> PreloadsType: | |
extra_preloads = extra_preloads or [] | |
if preloads is not None: | |
return [*preloads, *extra_preloads] | |
return [*self.BASE_PRELOADS, *extra_preloads] | |
def _build_render(self, | |
request: RequestType, | |
render: Optional[RenderFunction], | |
render_head: Optional[RenderFunction], | |
render_body: Optional[RenderFunction], | |
render_foot: Optional[RenderFunction], | |
head_template: Optional[str], | |
body_template: Optional[str], | |
foot_template: Optional[str], | |
context: Optional[ContextType], | |
extra_context: Optional[ContextType]) -> RenderFunction: | |
if render: | |
return render | |
extra_context = extra_context or {} | |
if context is not None: | |
context = {**context, **extra_context} | |
else: | |
context = {**self.BASE_CONTEXT, **extra_context} | |
def combined_render() -> RenderContent: | |
if not (render_head or head_template or self.BASE_TEMPLATE_HEAD): | |
raise ValueError('Missing render_head or head_template argument (and no default was defined in settings.BASE_TEMPLATE_HEAD)') | |
if not (render_body or body_template or self.BASE_TEMPLATE_BODY): | |
raise ValueError('Missing render_body or body_template argument (and no default was defined in settings.BASE_TEMPLATE_BODY)') | |
if not (render_foot or foot_template or self.BASE_TEMPLATE_FOOT): | |
raise ValueError('Missing render_foot or foot_template argument (and no default was defined in settings.BASE_TEMPLATE_FOOT)') | |
# yield immediately to force response.write to send headers early | |
yield '' | |
# then incrementally render and yield the head, body, and foot segments | |
if render_head: | |
yield from render_head() | |
else: | |
yield render_to_string( | |
head_template or self.BASE_TEMPLATE_HEAD, | |
context=context, | |
request=request, | |
) | |
if render_body: | |
yield from render_body() | |
else: | |
yield render_to_string( | |
body_template or self.BASE_TEMPLATE_BODY, | |
context=context, | |
request=request, | |
) | |
if render_foot: | |
yield from render_foot() | |
else: | |
yield render_to_string( | |
foot_template or self.BASE_TEMPLATE_FOOT, | |
context=context, | |
request=request, | |
) | |
return combined_render | |
class TurboStreamingResponse(BaseTurboStreamingResponse, TurboResponse): | |
pass | |
############################## Full Example Usage ############################## | |
# import sys | |
# import traceback | |
# from django.views import View | |
# from django.shortcuts import redirect | |
# from django.contrib.auth.mixins import LoginRequiredMixin | |
# from http2 import get_preloads_from_template | |
# | |
# | |
# ### Helpers for the example code | |
# | |
# class ExampleSettings: | |
# DEBUG = True | |
# GIT_SHA = 'e8e83f5' | |
# HOSTNAME = 'example.local' | |
# ENV = 'DEV' | |
# BASE_URL = 'https://example.local:8000' | |
# HTTP2_PRELOAD_HEADERS = True | |
# HTTP2_SERVER_PUSH = True | |
# BASE_TEMPLATE = 'base.html' | |
# BASE_TEMPLATE_HEAD = 'base_head.html' | |
# BASE_TEMPLATE_BODY = 'base_body.html' | |
# BASE_TEMPLATE_FOOT = 'base_foot.html' | |
# BASE_HEADERS: HeadersType = { | |
# 'X-GIT-SHA': GIT_SHA, | |
# 'X-DEBUG': str(DEBUG), | |
# } | |
# BASE_PRELOADS: PreloadsType = [ | |
# *get_preloads_from_template(BASE_TEMPLATE), | |
# # can add more preloads used on every page here: | |
# # 'css/base.css', | |
# # 'js/base.js', | |
# ] | |
# BASE_CONTEXT: ContextType = { | |
# 'HOSTNAME': HOSTNAME, | |
# 'GIT_SHA': GIT_SHA, | |
# 'DEBUG': DEBUG, | |
# 'ENV': ENV, | |
# 'BASE_URL': BASE_URL, | |
# } | |
# | |
# settings = ExampleSettings() | |
# | |
# | |
# | |
# ### Example Class-Based View Usage | |
# | |
# class SomeExpensiveView(LoginRequiredMixin, View): | |
# template = 'ui/example.html' # doesn't extend base.html, just contains <main>... content ...</main> | |
# | |
# def get(self, request: RequestType) -> Optional[ResponseType]: | |
# org = request.user.org | |
# if org.is_disabled: | |
# return redirect(f'/admin/core/organization/{org.id}/change/') | |
# | |
# def render_body() -> RenderContent: | |
# context = { | |
# **TurboStreamingResponse.get_context(request), | |
# 'users': {u.id: u.__json__() for u in User.objects.all()}, | |
# } | |
# yield render_to_string(self.template, context, request=request) | |
# | |
# expensive_sum = sum( | |
# u.relation_set.filter(some_really__hard__query='abc').count() | |
# for u in context['users'].values() | |
# ) | |
# yield f'<div class="footer-info">Total count: {expensive_sum}</div>' | |
# | |
# return TurboStreamingResponse(request, render_body) | |
# | |
# | |
# ### Example Function-Based View Usage | |
# | |
# def some_expensive_view(request: RequestType) -> ResponseType: | |
# template = 'ui/example.html' | |
# | |
# org = request.user.org | |
# if org.is_disabled: | |
# return redirect(f'/admin/core/organization/{org.id}/change/') | |
# | |
# def render_body() -> RenderContent: | |
# body_context = { | |
# **TurboStreamingResponse.get_context(request), | |
# 'users': {u.id: u.__json__() for u in User.objects.all()}, | |
# } | |
# yield render_to_string(template, body_context, request=request) | |
# | |
# expensive_sum = sum( | |
# u.relation_set.filter(some_really__hard__query='abc').count() | |
# for u in body_context['users'].values() | |
# ) | |
# yield f'<div class="footer-info">Total count: {expensive_sum}</div>' | |
# | |
# return TurboStreamingResponse(request, render_body) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment