Skip to content

Commit 0951ab7

Browse files
authored
Proposal: Refactor render to create InertiaResponse class (#61)
* Refactor to class based views * Refactor to class based views * cleanup * move to a mixin for better flexibility * PR review feedback
1 parent ac19cfa commit 0951ab7

File tree

9 files changed

+226
-97
lines changed

9 files changed

+226
-97
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ def index(request):
9595
}
9696
```
9797

98+
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.
99+
100+
```python
101+
from inertia import InertiaResponse
102+
from .models import Event
103+
104+
def index(request):
105+
return InertiaResponse(
106+
request,
107+
'Event/Index',
108+
props={
109+
'events': Event.objects.all()
110+
}
111+
)
112+
```
113+
98114
### Shared Data
99115

100116
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.

inertia/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .http import inertia, render, location
1+
from .http import inertia, render, location, InertiaResponse
22
from .utils import lazy, optional, defer, merge
33
from .share import share

inertia/helpers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
def deep_transform_callables(prop):
2+
if not isinstance(prop, dict):
3+
return prop() if callable(prop) else prop
4+
5+
for key in list(prop.keys()):
6+
prop[key] = deep_transform_callables(prop[key])
7+
8+
return prop
9+
10+
def validate_type(value, name, expected_type):
11+
if not isinstance(value, expected_type):
12+
raise TypeError(f"Expected {expected_type.__name__} for {name}, got {type(value).__name__}")
13+
14+
return value

inertia/http.py

Lines changed: 138 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,191 @@
11
from http import HTTPStatus
2-
from django.http import HttpResponse, JsonResponse
3-
from django.shortcuts import render as base_render
2+
from django.template.loader import render_to_string
3+
from django.http import HttpResponse
44
from .settings import settings
55
from json import dumps as json_encode
66
from functools import wraps
77
import requests
88
from .prop_classes import IgnoreOnFirstLoadProp, DeferredProp, MergeableProp
9+
from .helpers import deep_transform_callables, validate_type
910

1011
INERTIA_REQUEST_ENCRYPT_HISTORY = "_inertia_encrypt_history"
1112
INERTIA_SESSION_CLEAR_HISTORY = "_inertia_clear_history"
1213

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

17-
def partial_keys():
18-
return request.headers.get('X-Inertia-Partial-Data', '').split(',')
17+
class InertiaRequest:
18+
def __init__(self, request):
19+
self.request = request
20+
21+
def __getattr__(self, name):
22+
return getattr(self.request, name)
23+
24+
@property
25+
def headers(self):
26+
return self.request.headers
27+
28+
@property
29+
def inertia(self):
30+
return self.request.inertia.all() if hasattr(self.request, 'inertia') else {}
31+
32+
def is_a_partial_render(self, component):
33+
return 'X-Inertia-Partial-Data' in self.headers and self.headers.get('X-Inertia-Partial-Component', '') == component
1934

20-
def deep_transform_callables(prop):
21-
if not isinstance(prop, dict):
22-
return prop() if callable(prop) else prop
23-
24-
for key in list(prop.keys()):
25-
prop[key] = deep_transform_callables(prop[key])
35+
def partial_keys(self):
36+
return self.headers.get('X-Inertia-Partial-Data', '').split(',')
37+
38+
def reset_keys(self):
39+
return self.headers.get('X-Inertia-Reset', '').split(',')
40+
41+
def is_inertia(self):
42+
return 'X-Inertia' in self.headers
43+
44+
def should_encrypt_history(self):
45+
return validate_type(
46+
getattr(self.request, INERTIA_REQUEST_ENCRYPT_HISTORY, settings.INERTIA_ENCRYPT_HISTORY),
47+
expected_type=bool,
48+
name="encrypt_history"
49+
)
50+
51+
class BaseInertiaResponseMixin:
52+
def page_data(self):
53+
clear_history = validate_type(
54+
self.request.session.pop(INERTIA_SESSION_CLEAR_HISTORY, False),
55+
expected_type=bool,
56+
name="clear_history"
57+
)
58+
59+
_page = {
60+
'component': self.component,
61+
'props': self.build_props(),
62+
'url': self.request.get_full_path(),
63+
'version': settings.INERTIA_VERSION,
64+
'encryptHistory': self.request.should_encrypt_history(),
65+
'clearHistory': clear_history,
66+
}
2667

27-
return prop
68+
_deferred_props = self.build_deferred_props()
69+
if _deferred_props:
70+
_page['deferredProps'] = _deferred_props
71+
72+
_merge_props = self.build_merge_props()
73+
if _merge_props:
74+
_page['mergeProps'] = _merge_props
75+
76+
return _page
2877

29-
def build_props():
78+
def build_props(self):
3079
_props = {
31-
**(request.inertia.all() if hasattr(request, 'inertia') else {}),
32-
**props,
80+
**(self.request.inertia),
81+
**self.props,
3382
}
3483

3584
for key in list(_props.keys()):
36-
if is_a_partial_render():
37-
if key not in partial_keys():
85+
if self.request.is_a_partial_render(self.component):
86+
if key not in self.request.partial_keys():
3887
del _props[key]
3988
else:
4089
if isinstance(_props[key], IgnoreOnFirstLoadProp):
4190
del _props[key]
4291

4392
return deep_transform_callables(_props)
4493

45-
def build_deferred_props():
46-
if is_a_partial_render():
94+
def build_deferred_props(self):
95+
if self.request.is_a_partial_render(self.component):
4796
return None
4897

4998
_deferred_props = {}
50-
for key, prop in props.items():
99+
for key, prop in self.props.items():
51100
if isinstance(prop, DeferredProp):
52101
_deferred_props.setdefault(prop.group, []).append(key)
53102

54103
return _deferred_props
55-
56-
def build_merge_props():
57-
reset_keys = request.headers.get('X-Inertia-Reset', '').split(',')
58104

105+
def build_merge_props(self):
59106
return [
60107
key
61-
for key, prop in props.items()
108+
for key, prop in self.props.items()
62109
if (
63110
isinstance(prop, MergeableProp)
64111
and prop.should_merge()
65-
and key not in reset_keys
112+
and key not in self.request.reset_keys()
66113
)
67114
]
68-
69-
def render_ssr():
70-
data = json_encode(page_data(), cls=settings.INERTIA_JSON_ENCODER)
71-
response = requests.post(
72-
f"{settings.INERTIA_SSR_URL}/render",
73-
data=data,
74-
headers={"Content-Type": "application/json"},
115+
116+
def build_first_load(self, data):
117+
context, template = self.build_first_load_context_and_template(data)
118+
119+
return render_to_string(
120+
template,
121+
{
122+
'inertia_layout': settings.INERTIA_LAYOUT,
123+
**context,
124+
},
125+
self.request,
126+
using=None,
75127
)
76-
response.raise_for_status()
77-
return base_render(request, 'inertia_ssr.html', {
78-
'inertia_layout': settings.INERTIA_LAYOUT,
79-
**response.json()
80-
})
81128

82-
def page_data():
83-
encrypt_history = getattr(request, INERTIA_REQUEST_ENCRYPT_HISTORY, settings.INERTIA_ENCRYPT_HISTORY)
84-
if not isinstance(encrypt_history, bool):
85-
raise TypeError(f"Expected boolean for encrypt_history, got {type(encrypt_history).__name__}")
129+
130+
def build_first_load_context_and_template(self, data):
131+
if settings.INERTIA_SSR_ENABLED:
132+
try:
133+
response = requests.post(
134+
f"{settings.INERTIA_SSR_URL}/render",
135+
data=data,
136+
headers={"Content-Type": "application/json"},
137+
)
138+
response.raise_for_status()
139+
return {
140+
**response.json(),
141+
**self.template_data,
142+
}, INERTIA_SSR_TEMPLATE
143+
except Exception:
144+
pass
145+
146+
return {
147+
'page': data,
148+
**(self.template_data),
149+
}, INERTIA_TEMPLATE
86150

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

91-
_page = {
92-
'component': component,
93-
'props': build_props(),
94-
'url': request.build_absolute_uri(),
95-
'version': settings.INERTIA_VERSION,
96-
'encryptHistory': encrypt_history,
97-
'clearHistory': clear_history,
98-
}
152+
class InertiaResponse(BaseInertiaResponseMixin, HttpResponse):
153+
json_encoder = settings.INERTIA_JSON_ENCODER
99154

100-
_deferred_props = build_deferred_props()
101-
if _deferred_props:
102-
_page['deferredProps'] = _deferred_props
103-
104-
_merge_props = build_merge_props()
105-
if _merge_props:
106-
_page['mergeProps'] = _merge_props
107-
108-
return _page
155+
def __init__(self, request, component, props=None, template_data=None, headers=None, *args, **kwargs):
156+
self.request = InertiaRequest(request)
157+
self.component = component
158+
self.props = props or {}
159+
self.template_data = template_data or {}
160+
_headers = headers or {}
161+
162+
data = json_encode(self.page_data(), cls=self.json_encoder)
109163

110-
if 'X-Inertia' in request.headers:
111-
return JsonResponse(
112-
data=page_data(),
113-
headers={
164+
if self.request.is_inertia():
165+
_headers = {
166+
**_headers,
114167
'Vary': 'X-Inertia',
115168
'X-Inertia': 'true',
116-
},
117-
encoder=settings.INERTIA_JSON_ENCODER,
169+
'Content-Type': 'application/json',
170+
}
171+
content = data
172+
else:
173+
content = self.build_first_load(data)
174+
175+
super().__init__(
176+
content=content,
177+
headers=_headers,
178+
*args,
179+
**kwargs,
118180
)
119181

120-
if settings.INERTIA_SSR_ENABLED:
121-
try:
122-
return render_ssr()
123-
except Exception:
124-
pass
125-
126-
return base_render(request, 'inertia.html', {
127-
'inertia_layout': settings.INERTIA_LAYOUT,
128-
'page': json_encode(page_data(), cls=settings.INERTIA_JSON_ENCODER),
129-
**template_data,
130-
})
182+
def render(request, component, props=None, template_data=None):
183+
return InertiaResponse(
184+
request,
185+
component,
186+
props or {},
187+
template_data or {}
188+
)
131189

132190
def location(location):
133191
return HttpResponse('', status=HTTPStatus.CONFLICT, headers={

inertia/test.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,48 @@
11
from django.test import TestCase, Client
22
from unittest.mock import patch
3-
from django.http.response import JsonResponse
3+
from django.template.loader import render_to_string as base_render_to_string
44
from inertia.settings import settings
55
from json import dumps, loads
66
from django.utils.html import escape
7-
from django.shortcuts import render
7+
8+
class ClientWithLastResponse:
9+
def __init__(self, client):
10+
self.client = client
11+
self.last_response = None
12+
13+
def get(self, *args, **kwargs):
14+
self.last_response = self.client.get(*args, **kwargs)
15+
return self.last_response
16+
17+
def __getattr__(self, name):
18+
return getattr(self.client, name)
819

920
class BaseInertiaTestCase:
1021
def setUp(self):
11-
self.inertia = Client(HTTP_X_INERTIA=True)
22+
self.inertia = ClientWithLastResponse(Client(HTTP_X_INERTIA=True))
23+
self.client = ClientWithLastResponse(Client())
24+
25+
def last_response(self):
26+
return self.inertia.last_response or self.client.last_response
1227

1328
def assertJSONResponse(self, response, json_obj):
14-
self.assertIsInstance(response, JsonResponse)
29+
self.assertEqual(response.headers['Content-Type'], 'application/json')
1530
self.assertEqual(response.json(), json_obj)
1631

1732
class InertiaTestCase(BaseInertiaTestCase, TestCase):
1833
def setUp(self):
1934
super().setUp()
2035

21-
self.mock_inertia = patch('inertia.http.base_render', wraps=render)
36+
self.mock_inertia = patch('inertia.http.render_to_string', wraps=base_render_to_string)
2237
self.mock_render = self.mock_inertia.start()
2338

2439
def tearDown(self):
2540
self.mock_inertia.stop()
2641

2742
def page(self):
28-
return loads(self.mock_render.call_args.args[2]['page'])
43+
page_data = self.mock_render.call_args[0][1]['page'] if self.mock_render.call_args else self.last_response().content
44+
45+
return loads(page_data)
2946

3047
def props(self):
3148
return self.page()['props']
@@ -37,8 +54,7 @@ def deferred_props(self):
3754
return self.page()['deferredProps']
3855

3956
def template_data(self):
40-
context = self.mock_render.call_args.args[2]
41-
57+
context = self.mock_render.call_args[0][1]
4258
return {key: context[key] for key in context if key not in ['page', 'inertia_layout']}
4359

4460
def component(self):
@@ -63,7 +79,7 @@ def inertia_page(url, component='TestComponent', props={}, template_data={}, def
6379
_page = {
6480
'component': component,
6581
'props': props,
66-
'url': f'http://testserver/{url}/',
82+
'url': f'/{url}/',
6783
'version': settings.INERTIA_VERSION,
6884
'encryptHistory': False,
6985
'clearHistory': False,

0 commit comments

Comments
 (0)