├── setup.cfg
├── inertia
├── tests
│ ├── __init__.py
│ ├── testapp
│ │ ├── layout.html
│ │ ├── apps.py
│ │ ├── models.py
│ │ ├── urls.py
│ │ └── views.py
│ ├── test_settings.py
│ ├── test_tests.py
│ ├── test_middleware.py
│ ├── test_encoder.py
│ ├── settings.py
│ ├── test_ssr.py
│ └── test_rendering.py
├── __init__.py
├── templates
│ ├── inertia.html
│ └── inertia_ssr.html
├── share.py
├── settings.py
├── utils.py
├── middleware.py
├── test.py
└── http.py
├── pytest.ini
├── CHANGELOG.md
├── pyproject.toml
├── LICENSE
├── .gitignore
└── README.md
/setup.cfg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/inertia/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | DJANGO_SETTINGS_MODULE = inertia.tests.settings
3 | django_find_project = false
4 |
--------------------------------------------------------------------------------
/inertia/__init__.py:
--------------------------------------------------------------------------------
1 | from .http import inertia, render
2 | from .utils import lazy
3 | from .share import share
4 |
--------------------------------------------------------------------------------
/inertia/tests/testapp/layout.html:
--------------------------------------------------------------------------------
1 | head--{% block inertia_head %}{% endblock inertia_head %}--head
2 | {% block inertia %}{% endblock inertia %}
--------------------------------------------------------------------------------
/inertia/templates/inertia.html:
--------------------------------------------------------------------------------
1 | {% extends inertia_layout %}
2 |
3 | {% block inertia %}
4 |
5 | {% endblock inertia %}
6 |
--------------------------------------------------------------------------------
/inertia/tests/testapp/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 | class TestAppConfig(AppConfig):
4 | name = 'inertia.tests.testapp'
5 | verbose_name = 'TestApp'
6 |
--------------------------------------------------------------------------------
/inertia/templates/inertia_ssr.html:
--------------------------------------------------------------------------------
1 | {% extends inertia_layout %}
2 |
3 | {% block inertia_head %}{% for tag in head %}{{tag|safe}}{% endfor %}{% endblock inertia_head %}
4 |
5 | {% block inertia %}{{body|safe}}{% endblock inertia %}
6 |
--------------------------------------------------------------------------------
/inertia/tests/testapp/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | class User(models.Model):
4 | name = models.CharField(max_length=255)
5 | password = models.CharField(max_length=255)
6 | birthdate = models.DateField()
7 | registered_at = models.DateTimeField()
8 |
--------------------------------------------------------------------------------
/inertia/share.py:
--------------------------------------------------------------------------------
1 | __all__ = ['share']
2 |
3 | class InertiaShare:
4 | def __init__(self):
5 | self.props = {}
6 |
7 | def set(self, **kwargs):
8 | self.props = {
9 | **self.props,
10 | **kwargs,
11 | }
12 |
13 | def all(self):
14 | return self.props
15 |
16 |
17 | def share(request, **kwargs):
18 | if not hasattr(request, 'inertia'):
19 | request.inertia = InertiaShare()
20 |
21 | request.inertia.set(**kwargs)
22 |
--------------------------------------------------------------------------------
/inertia/tests/testapp/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from . import views
3 |
4 | urlpatterns = [
5 | path('test/', views.test),
6 | path('empty/', views.empty_test),
7 | path('redirect/', views.redirect_test),
8 | path('props/', views.props_test),
9 | path('template_data/', views.template_data_test),
10 | path('lazy/', views.lazy_test),
11 | path('complex-props/', views.complex_props_test),
12 | path('share/', views.share_test),
13 | path('inertia-redirect/', views.inertia_redirect_test),
14 | ]
--------------------------------------------------------------------------------
/inertia/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings as django_settings
2 | from .utils import InertiaJsonEncoder
3 |
4 | __all__ = ['settings']
5 |
6 | class InertiaSettings:
7 | INERTIA_VERSION = '1.0'
8 | INERTIA_JSON_ENCODER = InertiaJsonEncoder
9 | INERTIA_SSR_URL = 'http://localhost:13714'
10 | INERTIA_SSR_ENABLED = False
11 |
12 | def __getattribute__(self, name):
13 | try:
14 | return getattr(django_settings, name)
15 | except AttributeError:
16 | return super().__getattribute__(name)
17 |
18 | settings = InertiaSettings()
19 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [0.5.0] - 2022-12-20
8 |
9 | * Automatically Include CSRF Token.
10 | * Switch to using Vary: X-Inertia headers. Thanks @swarakaka!
11 | * Bugfix for Inertia head tag rendering. Thanks @svengt!
12 |
13 | ## [0.4.1] - 2022-10-10
14 |
15 | * Bugfix to allow redirects to be returned from @inertia decorated views.
16 |
17 | ## [0.4.0] - ???
18 |
19 | * Initial release.
--------------------------------------------------------------------------------
/inertia/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | from inertia.test import InertiaTestCase
2 | from django.test import override_settings
3 |
4 | class SettingsTestCase(InertiaTestCase):
5 | @override_settings(
6 | INERTIA_VERSION='2.0'
7 | )
8 | def test_version_works(self):
9 | response = self.inertia.get('/empty/', HTTP_X_INERTIA_VERSION='2.0')
10 |
11 | self.assertEqual(response.status_code, 200)
12 |
13 | def test_version_fallsback(self):
14 | response = self.inertia.get('/empty/', HTTP_X_INERTIA_VERSION='1.0')
15 |
16 | self.assertEqual(response.status_code, 200)
17 |
18 | def test_layout(self):
19 | response = self.client.get('/empty/')
20 | self.assertTemplateUsed(response, 'layout.html')
21 |
--------------------------------------------------------------------------------
/inertia/utils.py:
--------------------------------------------------------------------------------
1 | from django.core.serializers.json import DjangoJSONEncoder
2 | from django.db import models
3 | from django.db.models.query import QuerySet
4 | from django.forms.models import model_to_dict as base_model_to_dict
5 |
6 | def model_to_dict(model):
7 | return base_model_to_dict(model, exclude=('password',))
8 |
9 | class InertiaJsonEncoder(DjangoJSONEncoder):
10 | def default(self, value):
11 | if isinstance(value, models.Model):
12 | return model_to_dict(value)
13 |
14 | if isinstance(value, QuerySet):
15 | return [model_to_dict(model) for model in value]
16 |
17 | return super().default(value)
18 |
19 | class LazyProp:
20 | def __init__(self, prop):
21 | self.prop = prop
22 |
23 | def __call__(self):
24 | return self.prop() if callable(self.prop) else self.prop
25 |
26 |
27 | def lazy(prop):
28 | return LazyProp(prop)
29 |
--------------------------------------------------------------------------------
/inertia/tests/test_tests.py:
--------------------------------------------------------------------------------
1 | from inertia.test import InertiaTestCase
2 |
3 | class TestTestCase(InertiaTestCase):
4 |
5 | def test_include_props(self):
6 | response = self.client.get('/props/')
7 |
8 | self.assertIncludesProps({'name': 'Brandon'})
9 |
10 | def test_has_exact_props(self):
11 | response = self.client.get('/props/')
12 |
13 | self.assertHasExactProps({'name': 'Brandon', 'sport': 'Hockey'})
14 |
15 | def test_has_template_data(self):
16 | response = self.client.get('/template_data/')
17 |
18 | self.assertIncludesTemplateData({'name': 'Brian'})
19 |
20 | def test_has_exact_template_data(self):
21 | response = self.client.get('/template_data/')
22 |
23 | self.assertHasExactTemplateData({'name': 'Brian', 'sport': 'Basketball'})
24 |
25 | def test_component_name(self):
26 | response = self.client.get('/props/')
27 |
28 | self.assertComponentUsed('TestComponent')
29 |
30 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "inertia-django"
3 | version = "0.5.0"
4 | description = "Django adapter for the InertiaJS framework"
5 | authors = ["Brandon Shar "]
6 | license = "MIT"
7 | readme = "README.md"
8 | repository = "https://github.com/inertiajs/inertia-django"
9 | homepage = "https://github.com/inertiajs/inertia-django"
10 | keywords = ["inertia", "inertiajs", "django"]
11 | classifiers = [
12 | "Programming Language :: Python :: 3",
13 | "License :: OSI Approved :: MIT License",
14 | "Operating System :: OS Independent",
15 | "Framework :: Django :: 4",
16 | "Topic :: Software Development :: Libraries :: Python Modules",
17 | ]
18 | packages = [
19 | { include = "inertia" },
20 | ]
21 |
22 | [tool.poetry.dependencies]
23 | python = "^3.8"
24 | django = "^4.0"
25 | requests = "^2"
26 |
27 | [tool.poetry.dev-dependencies]
28 | pytest = "^7.1.2"
29 | pytest-django = "^4.5.2"
30 |
31 | [build-system]
32 | requires = ["poetry-core>=1.0.0"]
33 | build-backend = "poetry.core.masonry.api"
34 |
--------------------------------------------------------------------------------
/inertia/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, Client, override_settings
2 | from inertia.test import InertiaTestCase
3 |
4 | class MiddlewareTestCase(InertiaTestCase):
5 | def test_anything(self):
6 | response = self.client.get('/test/')
7 |
8 | self.assertEqual(response.status_code, 200)
9 |
10 | def test_stale_versions_are_refreshed(self):
11 | response = self.inertia.get('/empty/',
12 | HTTP_X_INERTIA_VERSION='some-nonsense',
13 | )
14 |
15 | self.assertEqual(response.status_code, 409)
16 | self.assertEqual(response.headers['X-Inertia-Location'], 'http://testserver/empty/')
17 |
18 | def test_redirect_status(self):
19 | for http_method in ['post', 'patch', 'delete']:
20 | response = getattr(self.inertia, http_method)('/redirect/')
21 |
22 | self.assertEqual(response.status_code, 303)
23 |
24 |
25 | def test_a_request_not_from_inertia_is_ignored(self):
26 | response = self.client.get('/empty/',
27 | HTTP_X_INERTIA_VERSION='some-nonsense',
28 | )
29 |
30 | self.assertEqual(response.status_code, 200)
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Bellawatt
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/inertia/tests/test_encoder.py:
--------------------------------------------------------------------------------
1 | from inertia.tests.testapp.models import User
2 | from django.test import TestCase
3 | from inertia.utils import InertiaJsonEncoder
4 | from json import dumps
5 | from datetime import date, datetime
6 |
7 | class InertiaJsonEncoderTestCase(TestCase):
8 | def setUp(self):
9 | self.encode = lambda obj: dumps(obj, cls=InertiaJsonEncoder)
10 |
11 | def test_it_handles_models_with_dates_and_removes_passwords(self):
12 | user = User(
13 | name='Brandon',
14 | password='something-top-secret',
15 | birthdate=date(1987, 2, 15),
16 | registered_at=datetime(2022, 10, 31, 10, 13, 1),
17 | )
18 |
19 | self.assertEqual(
20 | dumps({'id': None, 'name': 'Brandon', 'birthdate': '1987-02-15', 'registered_at': '2022-10-31T10:13:01'}),
21 | self.encode(user)
22 | )
23 |
24 | def test_it_handles_querysets(self):
25 | User(
26 | name='Brandon',
27 | password='something-top-secret',
28 | birthdate=date(1987, 2, 15),
29 | registered_at=datetime(2022, 10, 31, 10, 13, 1),
30 | ).save()
31 |
32 | self.assertEqual(
33 | dumps([{'id': 1, 'name': 'Brandon', 'birthdate': '1987-02-15', 'registered_at': '2022-10-31T10:13:01'}]),
34 | self.encode(User.objects.all())
35 | )
--------------------------------------------------------------------------------
/inertia/tests/testapp/views.py:
--------------------------------------------------------------------------------
1 | from django.http.response import HttpResponse
2 | from django.shortcuts import redirect
3 | from django.utils.decorators import decorator_from_middleware
4 | from inertia import inertia, render, lazy, share
5 |
6 | class ShareMiddleware:
7 | def __init__(self, get_response):
8 | self.get_response = get_response
9 |
10 | def process_request(self, request):
11 | share(request,
12 | position=lambda: 'goalie',
13 | number=29,
14 | )
15 |
16 | def test(request):
17 | return HttpResponse('Hey good stuff')
18 |
19 | @inertia('TestComponent')
20 | def empty_test(request):
21 | return {}
22 |
23 | def redirect_test(request):
24 | return redirect(empty_test)
25 |
26 | @inertia('TestComponent')
27 | def inertia_redirect_test(request):
28 | return redirect(empty_test)
29 |
30 | @inertia('TestComponent')
31 | def props_test(request):
32 | return {
33 | 'name': 'Brandon',
34 | 'sport': 'Hockey',
35 | }
36 |
37 | def template_data_test(request):
38 | return render(request, 'TestComponent', template_data={
39 | 'name': 'Brian',
40 | 'sport': 'Basketball',
41 | })
42 |
43 | @inertia('TestComponent')
44 | def lazy_test(request):
45 | return {
46 | 'name': 'Brian',
47 | 'sport': lazy(lambda: 'Basketball'),
48 | 'grit': lazy(lambda: 'intense'),
49 | }
50 |
51 | @inertia('TestComponent')
52 | def complex_props_test(request):
53 | return {
54 | 'person': {
55 | 'name': lambda: 'Brandon',
56 | }
57 | }
58 |
59 | @decorator_from_middleware(ShareMiddleware)
60 | @inertia('TestComponent')
61 | def share_test(request):
62 | return {
63 | 'name': 'Brandon',
64 | }
--------------------------------------------------------------------------------
/inertia/middleware.py:
--------------------------------------------------------------------------------
1 | from .settings import settings
2 | from django.contrib import messages
3 | from django.http import HttpResponse
4 | from django.middleware.csrf import get_token
5 |
6 | class InertiaMiddleware:
7 | def __init__(self, get_response):
8 | self.get_response = get_response
9 |
10 | def __call__(self, request):
11 | response = self.get_response(request)
12 |
13 | # Inertia requests don't ever render templates, so they skip the typical Django
14 | # CSRF path. We'll manually add a CSRF token for every request here.
15 | get_token(request)
16 |
17 | if not self.is_inertia_request(request):
18 | return response
19 |
20 | if self.is_non_post_redirect(request, response):
21 | response.status_code = 303
22 |
23 | if self.is_stale(request):
24 | return self.force_refresh(request)
25 |
26 | return response
27 |
28 | def is_non_post_redirect(self, request, response):
29 | return self.is_redirect_request(response) and request.method in ['POST', 'PATCH', 'DELETE']
30 |
31 | def is_inertia_request(self, request):
32 | return 'X-Inertia' in request.headers
33 |
34 | def is_redirect_request(self, response):
35 | return response.status_code in [301, 302]
36 |
37 | def is_stale(self, request):
38 | return request.headers.get('X-Inertia-Version', settings.INERTIA_VERSION) != settings.INERTIA_VERSION
39 |
40 | def is_stale_inertia_get(self, request):
41 | return request.method == 'GET' and self.is_stale(request)
42 |
43 | def force_refresh(self, request):
44 | messages.get_messages(request).used = False
45 | return HttpResponse('', status=409, headers={
46 | 'X-Inertia-Location': request.build_absolute_uri(),
47 | })
--------------------------------------------------------------------------------
/inertia/tests/settings.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | BASE_DIR = Path(__file__).resolve().parent.parent
4 |
5 | INERTIA_LAYOUT = 'layout.html'
6 |
7 | MIDDLEWARE = [
8 | 'django.middleware.security.SecurityMiddleware',
9 | 'django.contrib.sessions.middleware.SessionMiddleware',
10 | 'django.middleware.common.CommonMiddleware',
11 | 'django.middleware.csrf.CsrfViewMiddleware',
12 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
13 | 'django.contrib.messages.middleware.MessageMiddleware',
14 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
15 | 'inertia.middleware.InertiaMiddleware',
16 | ]
17 |
18 | INSTALLED_APPS = [
19 | "django.contrib.admin",
20 | "django.contrib.auth",
21 | "django.contrib.contenttypes",
22 | "django.contrib.sessions",
23 | "django.contrib.sites",
24 | "inertia",
25 | "inertia.tests.testapp.apps.TestAppConfig"
26 | ]
27 |
28 | ROOT_URLCONF = 'inertia.tests.testapp.urls'
29 |
30 | TEMPLATES = [
31 | {
32 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
33 | 'DIRS': [
34 | BASE_DIR / 'tests/testapp',
35 | ],
36 | 'APP_DIRS': True,
37 | 'OPTIONS': {
38 | 'context_processors': [
39 | 'django.template.context_processors.debug',
40 | 'django.template.context_processors.request',
41 | 'django.contrib.auth.context_processors.auth',
42 | 'django.contrib.messages.context_processors.messages',
43 | ],
44 | },
45 | },
46 | ]
47 |
48 | # only in place to silence an error
49 | SECRET_KEY = 'django-insecure-3p_!uve+em7f45+74jh16)y)h00ve@9d2edh=cuebdsrbco%vb'
50 | DATABASES = {
51 | "default": {
52 | "ENGINE": "django.db.backends.sqlite3",
53 | "NAME": "unused"
54 | }
55 | }
56 |
57 | # silence a warning
58 | USE_TZ = False
59 |
--------------------------------------------------------------------------------
/inertia/tests/test_ssr.py:
--------------------------------------------------------------------------------
1 | from inertia.test import InertiaTestCase, inertia_page, inertia_div
2 | from django.test import override_settings
3 | from unittest.mock import patch, Mock
4 | from requests.exceptions import RequestException
5 |
6 | @override_settings(
7 | INERTIA_SSR_ENABLED=True,
8 | INERTIA_SSR_URL='ssr-url',
9 | INERTIA_VERSION='1.0',
10 | )
11 | class SSRTestCase(InertiaTestCase):
12 |
13 | @patch('inertia.http.requests')
14 | def test_it_returns_ssr_calls(self, mock_request):
15 | mock_response = Mock()
16 | mock_response.json.return_value = {
17 | 'body': 'Body Works
',
18 | 'head': 'Head works',
19 | }
20 |
21 | mock_request.post.return_value = mock_response
22 |
23 | response = self.client.get('/props/')
24 |
25 | mock_request.post.assert_called_once_with(
26 | 'ssr-url/render',
27 | json=inertia_page('props', props={'name': 'Brandon', 'sport': 'Hockey'}),
28 | )
29 | self.assertTemplateUsed('inertia_ssr.html')
30 | print(response.content)
31 | self.assertContains(response, 'Body Works
')
32 | self.assertContains(response, 'head--Head works--head')
33 |
34 |
35 | @patch('inertia.http.requests')
36 | def test_it_uses_inertia_if_inertia_requests_are_made(self, mock_requests):
37 | response = self.inertia.get('/props/')
38 |
39 | mock_requests.post.assert_not_called()
40 | self.assertJSONResponse(response, inertia_page('props', props={'name': 'Brandon', 'sport': 'Hockey'}))
41 |
42 | @patch('inertia.http.requests')
43 | def test_it_fallsback_on_failure(self, mock_requests):
44 | def uh_oh(*args, **kwargs):
45 | raise RequestException()
46 |
47 | mock_response = Mock()
48 | mock_response.raise_for_status.side_effect = uh_oh
49 | mock_requests.post.return_value = mock_response
50 |
51 | response = self.client.get('/props/')
52 | self.assertContains(response, inertia_div('props', props={'name': 'Brandon', 'sport': 'Hockey'}))
53 |
--------------------------------------------------------------------------------
/inertia/test.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, Client
2 | from unittest.mock import patch
3 | from django.http.response import JsonResponse
4 | from inertia.settings import settings
5 | from json import dumps, loads
6 | from django.utils.html import escape
7 | from django.shortcuts import render
8 |
9 | class BaseInertiaTestCase:
10 | def setUp(self):
11 | self.inertia = Client(HTTP_X_INERTIA=True)
12 |
13 | def assertJSONResponse(self, response, json_obj):
14 | self.assertIsInstance(response, JsonResponse)
15 | self.assertEqual(response.json(), json_obj)
16 |
17 | class InertiaTestCase(BaseInertiaTestCase, TestCase):
18 | def setUp(self):
19 | super().setUp()
20 |
21 | self.mock_inertia = patch('inertia.http.base_render', wraps=render)
22 | self.mock_render = self.mock_inertia.start()
23 |
24 | def tearDown(self):
25 | self.mock_inertia.stop()
26 |
27 | def page(self):
28 | return loads(self.mock_render.call_args.args[2]['page'])
29 |
30 | def props(self):
31 | return self.page()['props']
32 |
33 | def template_data(self):
34 | context = self.mock_render.call_args.args[2]
35 |
36 | return {key: context[key] for key in context if key not in ['page', 'inertia_layout']}
37 |
38 | def component(self):
39 | return self.page()['component']
40 |
41 | def assertIncludesProps(self, props):
42 | self.assertDictEqual(self.props(), {**self.props(), **props})
43 |
44 | def assertHasExactProps(self, props):
45 | self.assertDictEqual(self.props(), props)
46 |
47 | def assertIncludesTemplateData(self, template_data):
48 | self.assertDictEqual(self.template_data(), {**self.template_data(), **template_data})
49 |
50 | def assertHasExactTemplateData(self, template_data):
51 | self.assertDictEqual(self.template_data(), template_data)
52 |
53 | def assertComponentUsed(self, component_name):
54 | self.assertEqual(component_name, self.component())
55 |
56 | def inertia_page(url, component='TestComponent', props={}, template_data={}):
57 | return {
58 | 'component': component,
59 | 'props': props,
60 | 'url': f'http://testserver/{url}/',
61 | 'version': settings.INERTIA_VERSION,
62 | }
63 |
64 | def inertia_div(*args, **kwargs):
65 | page = inertia_page(*args, **kwargs)
66 | return f''
67 |
--------------------------------------------------------------------------------
/inertia/http.py:
--------------------------------------------------------------------------------
1 | from django.http import JsonResponse
2 | from django.shortcuts import render as base_render
3 | from .settings import settings
4 | from json import dumps as json_encode
5 | from functools import wraps
6 | import requests
7 | from .utils import LazyProp
8 |
9 | def render(request, component, props={}, template_data={}):
10 | def is_a_partial_render():
11 | return 'X-Inertia-Partial-Data' in request.headers and request.headers.get('X-Inertia-Partial-Component', '') == component
12 |
13 | def partial_keys():
14 | return request.headers.get('X-Inertia-Partial-Data', '').split(',')
15 |
16 | def deep_transform_callables(prop):
17 | if not isinstance(prop, dict):
18 | return prop() if callable(prop) else prop
19 |
20 | for key in list(prop.keys()):
21 | prop[key] = deep_transform_callables(prop[key])
22 |
23 | return prop
24 |
25 | def build_props():
26 | _props = {
27 | **(request.inertia.all() if hasattr(request, 'inertia') else {}),
28 | **props,
29 | }
30 |
31 | for key in list(_props.keys()):
32 | if is_a_partial_render():
33 | if key not in partial_keys():
34 | del _props[key]
35 | else:
36 | if isinstance(_props[key], LazyProp):
37 | del _props[key]
38 |
39 | return deep_transform_callables(_props)
40 |
41 | def render_ssr():
42 | data = json_encode(page_data(), cls=settings.INERTIA_JSON_ENCODER)
43 | response = requests.post(
44 | f"{settings.INERTIA_SSR_URL}/render",
45 | data=data,
46 | headers={"Content-Type": "application/json"},
47 | )
48 | response.raise_for_status()
49 | return base_render(request, 'inertia_ssr.html', {
50 | 'inertia_layout': settings.INERTIA_LAYOUT,
51 | **response.json()
52 | })
53 |
54 | def page_data():
55 | return {
56 | 'component': component,
57 | 'props': build_props(),
58 | 'url': request.build_absolute_uri(),
59 | 'version': settings.INERTIA_VERSION,
60 | }
61 |
62 | if 'X-Inertia' in request.headers:
63 | return JsonResponse(
64 | data=page_data(),
65 | headers={
66 | 'Vary': 'Accept',
67 | 'X-Inertia': 'true',
68 | },
69 | encoder=settings.INERTIA_JSON_ENCODER,
70 | )
71 |
72 | if settings.INERTIA_SSR_ENABLED:
73 | try:
74 | return render_ssr()
75 | except Exception:
76 | pass
77 |
78 | return base_render(request, 'inertia.html', {
79 | 'inertia_layout': settings.INERTIA_LAYOUT,
80 | 'page': json_encode(page_data(), cls=settings.INERTIA_JSON_ENCODER),
81 | **template_data,
82 | })
83 |
84 | def inertia(component):
85 | def decorator(func):
86 | @wraps(func)
87 | def inner(request, *args, **kwargs):
88 | props = func(request, *args, **kwargs)
89 |
90 | # if something other than a dict is returned, the user probably wants to return a specific response
91 | if not isinstance(props, dict):
92 | return props
93 |
94 | return render(request, component, props)
95 |
96 | return inner
97 |
98 | return decorator
99 |
--------------------------------------------------------------------------------
/inertia/tests/test_rendering.py:
--------------------------------------------------------------------------------
1 | from inertia.test import InertiaTestCase, inertia_div, inertia_page
2 |
3 | class FirstLoadTestCase(InertiaTestCase):
4 | def test_with_props(self):
5 | self.assertContains(
6 | self.client.get('/props/'),
7 | inertia_div('props', props={
8 | 'name': 'Brandon',
9 | 'sport': 'Hockey',
10 | })
11 | )
12 |
13 | def test_with_template_data(self):
14 | self.assertContains(
15 | self.client.get('/template_data/'),
16 | inertia_div('template_data', template_data={
17 | 'name': 'Brian',
18 | 'sport': 'Basketball',
19 | })
20 | )
21 |
22 | def test_with_no_data(self):
23 | self.assertContains(
24 | self.client.get('/empty/'),
25 | inertia_div('empty')
26 | )
27 |
28 | def test_proper_status_code(self):
29 | self.assertEqual(
30 | self.client.get('/empty/').status_code,
31 | 200
32 | )
33 |
34 | def test_template_rendered(self):
35 | self.assertTemplateUsed(self.client.get('/empty/'), 'inertia.html')
36 |
37 |
38 | class SubsequentLoadTestCase(InertiaTestCase):
39 | def test_with_props(self):
40 | self.assertJSONResponse(
41 | self.inertia.get('/props/'),
42 | inertia_page('props', props={
43 | 'name': 'Brandon',
44 | 'sport': 'Hockey',
45 | })
46 | )
47 |
48 | def test_with_template_data(self):
49 | self.assertJSONResponse(
50 | self.inertia.get('/template_data/'),
51 | inertia_page('template_data', template_data={
52 | 'name': 'Brian',
53 | 'sport': 'Basketball',
54 | })
55 | )
56 |
57 | def test_with_no_data(self):
58 | self.assertJSONResponse(
59 | self.inertia.get('/empty/'),
60 | inertia_page('empty')
61 | )
62 |
63 | def test_proper_status_code(self):
64 | self.assertEqual(
65 | self.inertia.get('/empty/').status_code,
66 | 200
67 | )
68 |
69 | def test_redirects_from_inertia_views(self):
70 | self.assertEqual(
71 | self.inertia.get('/inertia-redirect/').status_code,
72 | 302
73 | )
74 |
75 | class LazyPropsTestCase(InertiaTestCase):
76 | def test_lazy_props_are_not_included(self):
77 | self.assertJSONResponse(
78 | self.inertia.get('/lazy/'),
79 | inertia_page('lazy', props={'name': 'Brian'})
80 | )
81 |
82 | def test_lazy_props_are_included_when_requested(self):
83 | self.assertJSONResponse(
84 | self.inertia.get('/lazy/', HTTP_X_INERTIA_PARTIAL_DATA='sport,grit', HTTP_X_INERTIA_PARTIAL_COMPONENT='TestComponent'),
85 | inertia_page('lazy', props={'sport': 'Basketball', 'grit': 'intense'})
86 | )
87 |
88 | class ComplexPropsTestCase(InertiaTestCase):
89 | def test_nested_callable_props_work(self):
90 | self.assertJSONResponse(
91 | self.inertia.get('/complex-props/'),
92 | inertia_page('complex-props', props={'person': {'name': 'Brandon'}})
93 | )
94 |
95 | class ShareTestCase(InertiaTestCase):
96 | def test_that_shared_props_are_merged(self):
97 | self.assertJSONResponse(
98 | self.inertia.get('/share/'),
99 | inertia_page('share', props={'name': 'Brandon', 'position': 'goalie', 'number': 29})
100 | )
101 |
102 | class CSRFTestCase(InertiaTestCase):
103 | def test_that_csrf_inclusion_is_automatic(self):
104 | response = self.inertia.get('/props/')
105 |
106 | self.assertIsNotNone(response.cookies.get('csrftoken'))
107 |
108 | def test_that_csrf_is_included_even_on_initial_page_load(self):
109 | response = self.client.get('/props/')
110 |
111 | self.assertIsNotNone(response.cookies.get('csrftoken'))
112 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
161 |
162 | # because this is a package, we don't want to commit the lock file
163 | poetry.lock
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
4 | # Inertia.js Django Adapter
5 |
6 | ## Installation
7 |
8 | ### Backend
9 |
10 | Install the following python package via pip
11 | ```bash
12 | pip install inertia-django
13 | ```
14 |
15 | Add the Inertia app to your `INSTALLED_APPS` in `settings.py`
16 | ```python
17 | INSTALLED_APPS = [
18 | # django apps,
19 | 'inertia',
20 | # your project's apps,
21 | ]
22 | ```
23 |
24 | Add the Inertia middleware to your `MIDDLEWARE` in `settings.py`
25 | ```python
26 | MIDDLEWARE = [
27 | # django middleware,
28 | 'inertia.middleware.InertiaMiddleware',
29 | # your project's middleware,
30 | ]
31 | ```
32 |
33 | Finally, create a layout which exposes `{% block inertia %}{% endblock %}` in the body and set the path to this layout as `INERTIA_LAYOUT` in your `settings.py` file.
34 |
35 | Now you're all set!
36 |
37 | ### Frontend
38 |
39 | Django specific frontend docs coming soon. For now, we recommend installing [django_vite](https://github.com/MrBin99/django-vite)
40 | and following the commits on the Django Vite [example repo](https://github.com/MrBin99/django-vite-example). Once Vite is setup with
41 | your frontend of choice, just replace the contents of `entry.js` with [this file (example in react)](https://github.com/BrandonShar/inertia-rails-template/blob/main/app/frontend/entrypoints/application.jsx)
42 |
43 |
44 | You can also check out the official Inertia docs at https://inertiajs.com/.
45 |
46 | ### CSRF
47 |
48 | Django's CSRF tokens are tightly coupled with rendering templates so Inertia Django automatically handles adding the CSRF cookie for you to each Inertia response. Because the default names Django users for the CSRF headers don't match Axios (the Javascript request library Inertia uses), we'll need to either modify Axios's defaults OR Django's settings.
49 |
50 | **You only need to choose one of the following options, just pick whichever makes the most sense to you!**
51 |
52 | In your `entry.js` file
53 | ```javascript
54 | axios.defaults.xsrfHeaderName = "X-CSRFToken"
55 | axios.defaults.xsrfCookieName = "csrftoken"
56 | ```
57 | OR
58 |
59 | In your Django `settings.py` file
60 | ```python
61 | CSRF_HEADER_NAME = 'HTTP_X_XSRF_TOKEN'
62 | CSRF_COOKIE_NAME = 'XSRF-TOKEN'
63 | ```
64 |
65 | ## Usage
66 |
67 | ### Responses
68 |
69 | Render Inertia responses is simple, you can either use the provided inertia render function or, for the most common use case, the inertia decorator. The render function accepts four arguments, the first is your request object. The second is the name of the component you want to render from within your pages directory (without extension). The third argument is a dict of `props` that should be provided to your components. The final argument is `template_data`, for any variables you want to provide to your template, but this is much less common.
70 |
71 | ```python
72 | from inertia import render
73 | from .models import Event
74 |
75 | def index(request):
76 | return render(request, 'Event/Index', props={
77 | 'events': Event.objects.all()
78 | })
79 | ```
80 |
81 | Or use the simpler decorator for the most common use cases
82 |
83 | ```python
84 | from inertia import inertia
85 | from .models import Event
86 |
87 | @inertia('Event/Index')
88 | def index(request):
89 | return {
90 | 'events': Event.objects.all(),
91 | }
92 | ```
93 |
94 | ### Shared Data
95 |
96 | 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.
97 |
98 | ```python
99 | from inertia import share
100 | from django.conf import settings
101 | from .models import User
102 |
103 | def inertia_share(get_response):
104 | def middleware(request):
105 | share(request,
106 | app_name=settings.APP_NAME,
107 | user_count=lambda: User.objects.count(), # evaluated lazily at render time
108 | user=lambda: request.user, # evaluated lazily at render time
109 | )
110 |
111 | return get_response(request)
112 | return middleware
113 | ```
114 |
115 | ### Lazy Props
116 | On the front end, Inertia supports the concept of "partial reloads" where only the props requested
117 | are returned by the server. Sometimes, you may want to use this flow to avoid processing a particularly slow prop on the intial load. In this case, you can use `Lazy props`. Lazy props aren't evaluated unless they're specifically requested by name in a partial reload.
118 |
119 | ```python
120 | from inertia import lazy, inertia
121 |
122 | @inertia('ExampleComponent')
123 | def example(request):
124 | return {
125 | 'name': lambda: 'Brandon', # this will be rendered on the first load as usual
126 | 'data': lazy(lambda: some_long_calculation()), # this will only be run when specifically requested by partial props and WILL NOT be included on the initial load
127 | }
128 | ```
129 |
130 | ### Json Encoding
131 |
132 | Inertia Django ships with a custom JsonEncoder at `inertia.utils.InertiaJsonEncoder` that extends Django's
133 | `DjangoJSONEncoder` with additional logic to handle encoding models and Querysets. If you have other json
134 | encoding logic you'd prefer, you can set a new JsonEncoder via the settings.
135 |
136 | ### SSR
137 |
138 | #### Backend
139 | Enable SSR via the `INERTIA_SSR_URL` and `INERTIA_SSR_ENABLED` settings
140 |
141 | #### Frontend
142 | Coming Soon!
143 |
144 | ## Settings
145 |
146 | Inertia Django has a few different settings options that can be set from within your project's `settings.py` file. Some of them have defaults.
147 |
148 | The default config is shown below
149 |
150 | ```python
151 | INERTIA_VERSION = '1.0' # defaults to '1.0'
152 | INERTIA_LAYOUT = 'layout.html' # required and has no default
153 | INERTIA_JSON_ENCODER = CustomJsonEncoder # defaults to inertia.utils.InertiaJsonEncoder
154 | INERTIA_SSR_URL = 'http://localhost:13714' # defaults to http://localhost:13714
155 | INERTIA_SSR_ENABLED = False # defaults to False
156 | ```
157 |
158 | ## Testing
159 |
160 | Inertia Django ships with a custom TestCase to give you some nice helper methods and assertions.
161 | To use it, just make sure your TestCase inherits from `InertiaTestCase`. `InertiaTestCase` inherits from Django's `django.test.TestCase` so it includes transaction support and a client.
162 |
163 | ```python
164 | from inertia.test import InertiaTestCase
165 |
166 | class ExampleTestCase(InertiaTestCase):
167 | def test_show_assertions(self):
168 | self.client.get('/events/')
169 |
170 | # check the component
171 | self.assertComponentUsed('Event/Index')
172 |
173 | # access the component name
174 | self.assertEqual(self.component(), 'Event/Index')
175 |
176 | # props (including shared props)
177 | self.assertHasExactProps({name: 'Brandon', sport: 'hockey'})
178 | self.assertIncludesProps({sport: 'hockey'})
179 |
180 | # access props
181 | self.assertEquals(self.props()['name'], 'Brandon')
182 |
183 | # template data
184 | self.assertHasExactTemplateData({name: 'Brian', sport: 'basketball'})
185 | self.assertIncludesTemplateData({sport: 'basketball'})
186 |
187 | # access template data
188 | self.assertEquals(self.template_data()['name'], 'Brian')
189 | ```
190 |
191 | The inertia test helper also includes a special `inertia` client that pre-sets the inertia headers
192 | for you to simulate an inertia response. You can access and use it just like the normal client with commands like `self.inertia.get('/events/')`. When using the inertia client, inertia custom assertions **are not** enabled though, so only use it if you want to directly assert against the json response.
193 |
194 | ## Thank you
195 |
196 | A huge thank you to the community members who have worked on InertiaJS for Django before us. Parts of this repo were particularly inspired by [Andres Vargas](https://github.com/zodman) and [Samuel Girardin](https://github.com/girardinsamuel). Additional thanks to Andres for the Pypi project.
197 |
198 | *Maintained and sponsored by the team at [bellaWatt](https://bellawatt.com/)*
199 |
200 | [](https://bellawatt.com/)
201 |
--------------------------------------------------------------------------------