Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Refactor render to create InertiaResponse class #61

Merged
merged 5 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),
BrandonShar marked this conversation as resolved.
Show resolved Hide resolved
expected_type=bool,
name="clear_history"
)

_page = {
'component': self.component,
'props': self.build_props(),
'url': self.request.get_full_path(),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed thanks to @Rey092

'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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to introduce a logger here. This way people can monitor issues with the SSR service.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good idea! Does Django have something standard or would we need something extensible here?

Either way, let's break this idea out into a separate PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea to break it into a separate PR.

Python has a built in solution for this. This is an example implementation:

import logging

logger = logging.getLogger(__name__)

...

def build_first_load_context_and_template(self, data):
    if settings.INERTIA_SSR_ENABLED:
      try: 
        ...
      except Exception:
        logger.exception("Error while calling ssr render endpoint.")


return {
'page': data,
**(self.template_data),
BrandonShar marked this conversation as resolved.
Show resolved Hide resolved
}, 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this attribute should be moved to the mixin, as it depends on it?


_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
Loading