├── 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 | ![image](https://user-images.githubusercontent.com/6599653/114456558-032e2200-9bab-11eb-88bc-a19897f417ba.png) 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 | [![bellaWatt Logo](https://user-images.githubusercontent.com/6599653/114456832-5607d980-9bab-11eb-99c8-ab39867c384e.png)](https://bellawatt.com/) 201 | --------------------------------------------------------------------------------