Skip to content

Commit

Permalink
Proposal: Refactor render to create InertiaResponse class (#61)
Browse files Browse the repository at this point in the history
* Refactor to class based views

* Refactor to class based views

* cleanup

* move to a mixin for better flexibility

* PR review feedback
  • Loading branch information
BrandonShar authored Jan 8, 2025
1 parent ac19cfa commit 0951ab7
Show file tree
Hide file tree
Showing 9 changed files with 226 additions and 97 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ def index(request):
}
```

If you need more control, you can also directly return the InertiaResponse class. It has the same arguments as the render method and subclasses HttpResponse to accept of all its arguments as well.

```python
from inertia import InertiaResponse
from .models import Event

def index(request):
return InertiaResponse(
request,
'Event/Index',
props={
'events': Event.objects.all()
}
)
```

### Shared Data

If you have data that you want to be provided as a prop to every component (a common use-case is information about the authenticated user) you can use the `share` method. A common place to put this would be in some custom middleware.
Expand Down
2 changes: 1 addition & 1 deletion inertia/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .http import inertia, render, location
from .http import inertia, render, location, InertiaResponse
from .utils import lazy, optional, defer, merge
from .share import share
14 changes: 14 additions & 0 deletions inertia/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
def deep_transform_callables(prop):
if not isinstance(prop, dict):
return prop() if callable(prop) else prop

for key in list(prop.keys()):
prop[key] = deep_transform_callables(prop[key])

return prop

def validate_type(value, name, expected_type):
if not isinstance(value, expected_type):
raise TypeError(f"Expected {expected_type.__name__} for {name}, got {type(value).__name__}")

return value
218 changes: 138 additions & 80 deletions inertia/http.py
Original file line number Diff line number Diff line change
@@ -1,133 +1,191 @@
from http import HTTPStatus
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render as base_render
from django.template.loader import render_to_string
from django.http import HttpResponse
from .settings import settings
from json import dumps as json_encode
from functools import wraps
import requests
from .prop_classes import IgnoreOnFirstLoadProp, DeferredProp, MergeableProp
from .helpers import deep_transform_callables, validate_type

INERTIA_REQUEST_ENCRYPT_HISTORY = "_inertia_encrypt_history"
INERTIA_SESSION_CLEAR_HISTORY = "_inertia_clear_history"

def render(request, component, props={}, template_data={}):
def is_a_partial_render():
return 'X-Inertia-Partial-Data' in request.headers and request.headers.get('X-Inertia-Partial-Component', '') == component
INERTIA_TEMPLATE = 'inertia.html'
INERTIA_SSR_TEMPLATE = 'inertia_ssr.html'

def partial_keys():
return request.headers.get('X-Inertia-Partial-Data', '').split(',')
class InertiaRequest:
def __init__(self, request):
self.request = request

def __getattr__(self, name):
return getattr(self.request, name)

@property
def headers(self):
return self.request.headers

@property
def inertia(self):
return self.request.inertia.all() if hasattr(self.request, 'inertia') else {}

def is_a_partial_render(self, component):
return 'X-Inertia-Partial-Data' in self.headers and self.headers.get('X-Inertia-Partial-Component', '') == component

def deep_transform_callables(prop):
if not isinstance(prop, dict):
return prop() if callable(prop) else prop

for key in list(prop.keys()):
prop[key] = deep_transform_callables(prop[key])
def partial_keys(self):
return self.headers.get('X-Inertia-Partial-Data', '').split(',')

def reset_keys(self):
return self.headers.get('X-Inertia-Reset', '').split(',')

def is_inertia(self):
return 'X-Inertia' in self.headers

def should_encrypt_history(self):
return validate_type(
getattr(self.request, INERTIA_REQUEST_ENCRYPT_HISTORY, settings.INERTIA_ENCRYPT_HISTORY),
expected_type=bool,
name="encrypt_history"
)

class BaseInertiaResponseMixin:
def page_data(self):
clear_history = validate_type(
self.request.session.pop(INERTIA_SESSION_CLEAR_HISTORY, False),
expected_type=bool,
name="clear_history"
)

_page = {
'component': self.component,
'props': self.build_props(),
'url': self.request.get_full_path(),
'version': settings.INERTIA_VERSION,
'encryptHistory': self.request.should_encrypt_history(),
'clearHistory': clear_history,
}

return prop
_deferred_props = self.build_deferred_props()
if _deferred_props:
_page['deferredProps'] = _deferred_props

_merge_props = self.build_merge_props()
if _merge_props:
_page['mergeProps'] = _merge_props

return _page

def build_props():
def build_props(self):
_props = {
**(request.inertia.all() if hasattr(request, 'inertia') else {}),
**props,
**(self.request.inertia),
**self.props,
}

for key in list(_props.keys()):
if is_a_partial_render():
if key not in partial_keys():
if self.request.is_a_partial_render(self.component):
if key not in self.request.partial_keys():
del _props[key]
else:
if isinstance(_props[key], IgnoreOnFirstLoadProp):
del _props[key]

return deep_transform_callables(_props)

def build_deferred_props():
if is_a_partial_render():
def build_deferred_props(self):
if self.request.is_a_partial_render(self.component):
return None

_deferred_props = {}
for key, prop in props.items():
for key, prop in self.props.items():
if isinstance(prop, DeferredProp):
_deferred_props.setdefault(prop.group, []).append(key)

return _deferred_props

def build_merge_props():
reset_keys = request.headers.get('X-Inertia-Reset', '').split(',')

def build_merge_props(self):
return [
key
for key, prop in props.items()
for key, prop in self.props.items()
if (
isinstance(prop, MergeableProp)
and prop.should_merge()
and key not in reset_keys
and key not in self.request.reset_keys()
)
]

def render_ssr():
data = json_encode(page_data(), cls=settings.INERTIA_JSON_ENCODER)
response = requests.post(
f"{settings.INERTIA_SSR_URL}/render",
data=data,
headers={"Content-Type": "application/json"},

def build_first_load(self, data):
context, template = self.build_first_load_context_and_template(data)

return render_to_string(
template,
{
'inertia_layout': settings.INERTIA_LAYOUT,
**context,
},
self.request,
using=None,
)
response.raise_for_status()
return base_render(request, 'inertia_ssr.html', {
'inertia_layout': settings.INERTIA_LAYOUT,
**response.json()
})

def page_data():
encrypt_history = getattr(request, INERTIA_REQUEST_ENCRYPT_HISTORY, settings.INERTIA_ENCRYPT_HISTORY)
if not isinstance(encrypt_history, bool):
raise TypeError(f"Expected boolean for encrypt_history, got {type(encrypt_history).__name__}")

def build_first_load_context_and_template(self, data):
if settings.INERTIA_SSR_ENABLED:
try:
response = requests.post(
f"{settings.INERTIA_SSR_URL}/render",
data=data,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return {
**response.json(),
**self.template_data,
}, INERTIA_SSR_TEMPLATE
except Exception:
pass

return {
'page': data,
**(self.template_data),
}, INERTIA_TEMPLATE

clear_history = request.session.pop(INERTIA_SESSION_CLEAR_HISTORY, False)
if not isinstance(clear_history, bool):
raise TypeError(f"Expected boolean for clear_history, got {type(clear_history).__name__}")

_page = {
'component': component,
'props': build_props(),
'url': request.build_absolute_uri(),
'version': settings.INERTIA_VERSION,
'encryptHistory': encrypt_history,
'clearHistory': clear_history,
}
class InertiaResponse(BaseInertiaResponseMixin, HttpResponse):
json_encoder = settings.INERTIA_JSON_ENCODER

_deferred_props = build_deferred_props()
if _deferred_props:
_page['deferredProps'] = _deferred_props

_merge_props = build_merge_props()
if _merge_props:
_page['mergeProps'] = _merge_props

return _page
def __init__(self, request, component, props=None, template_data=None, headers=None, *args, **kwargs):
self.request = InertiaRequest(request)
self.component = component
self.props = props or {}
self.template_data = template_data or {}
_headers = headers or {}

data = json_encode(self.page_data(), cls=self.json_encoder)

if 'X-Inertia' in request.headers:
return JsonResponse(
data=page_data(),
headers={
if self.request.is_inertia():
_headers = {
**_headers,
'Vary': 'X-Inertia',
'X-Inertia': 'true',
},
encoder=settings.INERTIA_JSON_ENCODER,
'Content-Type': 'application/json',
}
content = data
else:
content = self.build_first_load(data)

super().__init__(
content=content,
headers=_headers,
*args,
**kwargs,
)

if settings.INERTIA_SSR_ENABLED:
try:
return render_ssr()
except Exception:
pass

return base_render(request, 'inertia.html', {
'inertia_layout': settings.INERTIA_LAYOUT,
'page': json_encode(page_data(), cls=settings.INERTIA_JSON_ENCODER),
**template_data,
})
def render(request, component, props=None, template_data=None):
return InertiaResponse(
request,
component,
props or {},
template_data or {}
)

def location(location):
return HttpResponse('', status=HTTPStatus.CONFLICT, headers={
Expand Down
34 changes: 25 additions & 9 deletions inertia/test.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,48 @@
from django.test import TestCase, Client
from unittest.mock import patch
from django.http.response import JsonResponse
from django.template.loader import render_to_string as base_render_to_string
from inertia.settings import settings
from json import dumps, loads
from django.utils.html import escape
from django.shortcuts import render

class ClientWithLastResponse:
def __init__(self, client):
self.client = client
self.last_response = None

def get(self, *args, **kwargs):
self.last_response = self.client.get(*args, **kwargs)
return self.last_response

def __getattr__(self, name):
return getattr(self.client, name)

class BaseInertiaTestCase:
def setUp(self):
self.inertia = Client(HTTP_X_INERTIA=True)
self.inertia = ClientWithLastResponse(Client(HTTP_X_INERTIA=True))
self.client = ClientWithLastResponse(Client())

def last_response(self):
return self.inertia.last_response or self.client.last_response

def assertJSONResponse(self, response, json_obj):
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.headers['Content-Type'], 'application/json')
self.assertEqual(response.json(), json_obj)

class InertiaTestCase(BaseInertiaTestCase, TestCase):
def setUp(self):
super().setUp()

self.mock_inertia = patch('inertia.http.base_render', wraps=render)
self.mock_inertia = patch('inertia.http.render_to_string', wraps=base_render_to_string)
self.mock_render = self.mock_inertia.start()

def tearDown(self):
self.mock_inertia.stop()

def page(self):
return loads(self.mock_render.call_args.args[2]['page'])
page_data = self.mock_render.call_args[0][1]['page'] if self.mock_render.call_args else self.last_response().content

return loads(page_data)

def props(self):
return self.page()['props']
Expand All @@ -37,8 +54,7 @@ def deferred_props(self):
return self.page()['deferredProps']

def template_data(self):
context = self.mock_render.call_args.args[2]

context = self.mock_render.call_args[0][1]
return {key: context[key] for key in context if key not in ['page', 'inertia_layout']}

def component(self):
Expand All @@ -63,7 +79,7 @@ def inertia_page(url, component='TestComponent', props={}, template_data={}, def
_page = {
'component': component,
'props': props,
'url': f'http://testserver/{url}/',
'url': f'/{url}/',
'version': settings.INERTIA_VERSION,
'encryptHistory': False,
'clearHistory': False,
Expand Down
Loading

0 comments on commit 0951ab7

Please sign in to comment.