├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── css │ └── extra.css └── index.md ├── drf_inertia ├── __init__.py ├── config.py ├── decorators.py ├── exceptions.py ├── negotiation.py └── serializers.py ├── mkdocs.yml ├── pytest.ini ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── models.py ├── test_decorators.py └── test_negotiation.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | 6 | html/ 7 | htmlcov/ 8 | coverage/ 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | MANIFEST 13 | 14 | bin/ 15 | include/ 16 | lib/ 17 | local/ 18 | 19 | !.gitignore 20 | !.travis.yml 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | env: 6 | - TOX_ENV=py27-flake8 7 | - TOX_ENV=py27-docs 8 | - TOX_ENV=py27-django1.6-drf2.4 9 | - TOX_ENV=py27-django1.6-drf3.0 10 | - TOX_ENV=py27-django1.6-drf3.1 11 | - TOX_ENV=py27-django1.7-drf2.4 12 | - TOX_ENV=py27-django1.7-drf3.0 13 | - TOX_ENV=py27-django1.7-drf3.1 14 | - TOX_ENV=py27-django1.8-drf2.4 15 | - TOX_ENV=py27-django1.8-drf3.0 16 | - TOX_ENV=py27-django1.8-drf3.1 17 | - TOX_ENV=py33-django1.6-drf2.4 18 | - TOX_ENV=py33-django1.6-drf3.0 19 | - TOX_ENV=py33-django1.6-drf3.1 20 | - TOX_ENV=py33-django1.7-drf2.4 21 | - TOX_ENV=py33-django1.7-drf3.0 22 | - TOX_ENV=py33-django1.7-drf3.1 23 | - TOX_ENV=py33-django1.8-drf2.4 24 | - TOX_ENV=py33-django1.8-drf3.0 25 | - TOX_ENV=py33-django1.8-drf3.1 26 | - TOX_ENV=py34-django1.6-drf2.4 27 | - TOX_ENV=py34-django1.6-drf3.0 28 | - TOX_ENV=py34-django1.6-drf3.1 29 | - TOX_ENV=py34-django1.7-drf2.4 30 | - TOX_ENV=py34-django1.7-drf3.0 31 | - TOX_ENV=py34-django1.7-drf3.1 32 | - TOX_ENV=py34-django1.8-drf2.4 33 | - TOX_ENV=py34-django1.8-drf3.0 34 | - TOX_ENV=py34-django1.8-drf3.1 35 | 36 | matrix: 37 | fast_finish: true 38 | 39 | install: 40 | - pip install tox 41 | 42 | script: 43 | - tox -e $TOX_ENV 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Rory Casey 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | recursive-exclude * __pycache__ 3 | recursive-exclude * *.py[co] 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-rest-inertia 2 | ====================================== 3 | 4 | |build-status-image| |pypi-version| 5 | 6 | Overview 7 | -------- 8 | 9 | A django rest framework adapter for Inertia https://inertiajs.com/ 10 | 11 | Requirements 12 | ------------ 13 | 14 | - Python (2.7, 3.3+) 15 | - Django (1.11, 2.2, 3.0) 16 | - Django REST Framework (2.4, 3.0, 3.1) 17 | 18 | Installation 19 | ------------ 20 | 21 | Install using ``pip``\ … 22 | 23 | .. code:: bash 24 | 25 | $ pip install django-rest-inertia 26 | 27 | Django Settings 28 | --------------- 29 | 30 | The following settings can be used: 31 | 32 | .. code:: python 33 | 34 | # the version to use for ASSET VERSIONING 35 | INERTIA_VERSION # default: "unversioned" 36 | 37 | # the HTML template for interia requests (can be overridden by the @intertia decorator) 38 | INERTIA_HTML_TEMPLATE # default: 'index.html' 39 | 40 | # the django template var the inertia json should be set to 41 | INERTIA_TEMPLATE_VAR # default: 'inertia_json' 42 | 43 | # A serializer that is passed the request as its "instance". The serialized values are 44 | # added to the props of every inertia response (except where 'only' is specified) 45 | INERTIA_SHARED_SERIALIZER # default: 'drf_inertia.serializers.DefaultSharedSerializer' 46 | 47 | # The exception handler for inertia requests 48 | # ensures that exceptions are returned in interia 49 | # format 50 | INERTIA_EXCEPTION_HANDLER # default: 'drf_inertia.exceptions.DefaultExceptionHandler' 51 | 52 | # The auth redirect is used in the default exception handler 53 | # to determine where to go when 401 or 403 errors are raised 54 | INERTIA_AUTH_REDIRECT # default: '/login' 55 | 56 | # if AUTH_REDIRECT_URL_NAME is specified use django.urls.reverse 57 | # to get the AUTH_REDIRECT instead 58 | INERTIA_AUTH_REDIRECT_URL_NAME # default: None 59 | 60 | Non-inertia settings: 61 | 62 | .. code:: python 63 | 64 | # To use inertia as it is with Laravel and Rails you should be using 65 | # Session Authentication 66 | REST_FRAMEWORK = { 67 | "DEFAULT_AUTHENTICATION_CLASSES": ( 68 | "rest_framework.authentication.SessionAuthentication", 69 | ) 70 | } 71 | 72 | # The default CSRF header name passed by axios 73 | CSRF_HEADER_NAME = "HTTP_X_XSRF_TOKEN" 74 | 75 | # The default CSRF cookie name that axios looks up 76 | CSRF_COOKIE_NAME = "XSRF-TOKEN" 77 | 78 | 79 | Views 80 | ----- 81 | 82 | To use django-inertia-rest, decorate your views with the ``@inertia`` decorator 83 | passing it the frontend component: 84 | 85 | .. code:: python 86 | 87 | from rest_framework import views, viewsets 88 | from rest_framework.response import Response 89 | from rest_framework.decorators import api_view 90 | 91 | from drf_inertia.decorators import inertia 92 | 93 | # on a function based view: 94 | @inertia("User/List") 95 | @api_view(["GET"]) 96 | def get_users(request, **kwargs): 97 | return Response(data={"users": []}) 98 | 99 | # on a class based view: 100 | @inertia("User/List") 101 | class UsersView(views.APIView): 102 | def get(self, request, **kwargs): 103 | return Response(data={"users": []}) 104 | 105 | Both these views would return the following: 106 | 107 | .. code:: HTTP 108 | 109 | GET: http://example.com/users 110 | Accept: text/html, application/xhtml+xml 111 | X-Inertia: true 112 | X-Inertia-Version: unversioned 113 | 114 | HTTP/1.1 200 OK 115 | Content-Type: application/json 116 | 117 | { 118 | "component": "User/List", 119 | "props": { 120 | "users": [] 121 | }, 122 | "url": "/users", 123 | "version": "unversioned" 124 | } 125 | 126 | Note that if you make a request to the API without the ``X-Inertia`` 127 | headers and using an ``Accept`` header that does not include html, 128 | then you will get a response as though there is no ``@inertia`` 129 | decorator: 130 | 131 | .. code:: HTTP 132 | 133 | GET: http://example.com/users 134 | Accept: application/json 135 | 136 | HTTP/1.1 200 OK 137 | Content-Type: application/json 138 | 139 | { 140 | "users": [] 141 | } 142 | 143 | 144 | For ViewSets, each action may need a different component: 145 | 146 | .. code:: python 147 | 148 | # on a viewset: 149 | @inertia("User/List", retrieve="Users/Detail") 150 | class UserViewSet(viewsets.ModelViewSet): 151 | queryset = User.objects.all() 152 | 153 | Or you can use the ``@component`` decorator: 154 | 155 | .. code:: python 156 | 157 | from drf_inertia.decorators import inertia, component 158 | 159 | @inertia("User/List") 160 | class UserViewSet(viewsets.ModelViewSet): 161 | queryset = User.objects.all() 162 | 163 | @component("User/Detail") 164 | def retrieve(self, request, pk=None): 165 | //... 166 | return Response(data=user_data) 167 | 168 | 169 | Shared data is added using a `SharedSerializer`. A default implementation 170 | `DefaultSharedSerializer` is provided which includes errors and flash data. 171 | 172 | The flash data makes use of Django's messages framework: 173 | 174 | .. code:: python 175 | 176 | from django.contrib import messages 177 | 178 | @inertia("User/List") 179 | class UserList(APIView): 180 | def get(self, request): 181 | messages.add_message(request, messages.INFO, 'Got users.') 182 | return Response(data=UserSerializer(User.objects.all(), many=True).data) 183 | 184 | # GET /users 185 | { 186 | "component": "User/List", 187 | "props": { 188 | "users": [...], 189 | "flash": {"info": "Got users."} 190 | }, 191 | "url": "/users", 192 | "version": "unversioned" 193 | } 194 | 195 | 196 | Exceptions 197 | ---------- 198 | 199 | Inertia decorated views also have a custom exception handler set. This will catch 200 | exceptions, add errors to `request.session`, and return a `303` response as the 201 | inertia protocol demands. 202 | 203 | By default the redirect will be to the current `request.path` but you can override 204 | this in your view using `set_error_redirect`. 205 | 206 | Errors added to djangos "request.session" object will show up in the errors 207 | field in `GET` responses via the `DefaultSharedSerializer`. 208 | 209 | .. code:: python 210 | 211 | from drf_inertia.exceptions import set_error_redirect 212 | 213 | @inertia("Users/List") 214 | class UserViewSet(viewsets.ModelViewSet): 215 | # ... 216 | 217 | def create(self, request): 218 | set_error_redirect(request, '/users/create) # or reverse('users-create') 219 | # this will invoke the inertia exception handler 220 | # which adds the error to the session and redirects 221 | # back to the request.path 222 | raises ValidationError("Cannot create user") 223 | 224 | 225 | # POST /users {"name": "John Smith", "email": "P@ssword"} 226 | # 227 | # 303 See Other 228 | # Location: /users/create 229 | 230 | # GET /users/create 231 | { 232 | "component": "User/Create", 233 | "props": { 234 | "users": [...], 235 | "errors": ["Cannot create user"] 236 | }, 237 | "url": "/users/create", 238 | "version": "unversioned" 239 | } 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | Testing 249 | ------- 250 | 251 | Install testing requirements. 252 | 253 | .. code:: bash 254 | 255 | $ pip install -r requirements.txt 256 | 257 | Run with runtests. 258 | 259 | .. code:: bash 260 | 261 | $ ./runtests.py 262 | 263 | You can also use the excellent `tox`_ testing tool to run the tests 264 | against all supported versions of Python and Django. Install tox 265 | globally, and then simply run: 266 | 267 | .. code:: bash 268 | 269 | $ tox 270 | 271 | Documentation 272 | ------------- 273 | 274 | To build the documentation, you’ll need to install ``mkdocs``. 275 | 276 | .. code:: bash 277 | 278 | $ pip install mkdocs 279 | 280 | To preview the documentation: 281 | 282 | .. code:: bash 283 | 284 | $ mkdocs serve 285 | Running at: http://127.0.0.1:8000/ 286 | 287 | To build the documentation: 288 | 289 | .. code:: bash 290 | 291 | $ mkdocs build 292 | 293 | .. _tox: http://tox.readthedocs.org/en/latest/ 294 | 295 | .. |build-status-image| image:: https://secure.travis-ci.org/rojoca/django-rest-inertia.svg?branch=master 296 | :target: http://travis-ci.org/rojoca/django-rest-inertia?branch=master 297 | .. |pypi-version| image:: https://img.shields.io/pypi/v/django-rest-inertia.svg 298 | :target: https://pypi.python.org/pypi/django-rest-inertia 299 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | body.homepage div.col-md-9 h1:first-of-type { 2 | text-align: center; 3 | font-size: 60px; 4 | font-weight: 300; 5 | margin-top: 0; 6 | } 7 | 8 | body.homepage div.col-md-9 p:first-of-type { 9 | text-align: center; 10 | } 11 | 12 | body.homepage .badges { 13 | text-align: right; 14 | } 15 | 16 | body.homepage .badges a { 17 | display: inline-block; 18 | } 19 | 20 | body.homepage .badges a img { 21 | padding: 0; 22 | margin: 0; 23 | } 24 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | --- 11 | 12 | # django-rest-inertia 13 | 14 | A server-side adapter for Inertia https://inertiajs.com/ 15 | 16 | --- 17 | 18 | ## Overview 19 | 20 | A server-side adapter for Inertia https://inertiajs.com/ 21 | 22 | ## Requirements 23 | 24 | * Python (2.7, 3.3, 3.4) 25 | * Django (1.6, 1.7) 26 | 27 | ## Installation 28 | 29 | Install using `pip`... 30 | 31 | ```bash 32 | $ pip install django-rest-inertia 33 | ``` 34 | 35 | ## Example 36 | 37 | TODO: Write example. 38 | 39 | ## Testing 40 | 41 | Install testing requirements. 42 | 43 | ```bash 44 | $ pip install -r requirements.txt 45 | ``` 46 | 47 | Run with runtests. 48 | 49 | ```bash 50 | $ ./runtests.py 51 | ``` 52 | 53 | You can also use the excellent [tox](http://tox.readthedocs.org/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run: 54 | 55 | ```bash 56 | $ tox 57 | ``` 58 | 59 | ## Documentation 60 | 61 | To build the documentation, you'll need to install `mkdocs`. 62 | 63 | ```bash 64 | $ pip install mkdocs 65 | ``` 66 | 67 | To preview the documentation: 68 | 69 | ```bash 70 | $ mkdocs serve 71 | Running at: http://127.0.0.1:8000/ 72 | ``` 73 | 74 | To build the documentation: 75 | 76 | ```bash 77 | $ mkdocs build 78 | ``` 79 | -------------------------------------------------------------------------------- /drf_inertia/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | -------------------------------------------------------------------------------- /drf_inertia/config.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | # the version to use for ASSET VERSIONING 5 | VERSION = getattr(settings, "INERTIA_VERSION", "unversioned") 6 | 7 | # the HTML template for interia requests (can be overridden by the @intertia decorator) 8 | TEMPLATE = getattr(settings, 'INERTIA_HTML_TEMPLATE', 'index.html') 9 | 10 | # the django template var the inertia json should be set to 11 | TEMPLATE_VAR = getattr(settings, 'INERTIA_TEMPLATE_VAR', 'inertia_json') 12 | 13 | SHARED_DATA_SERIALIZER = getattr(settings, 'INERTIA_SHARED_SERIALIZER', 'drf_inertia.serializers.DefaultSharedSerializer') 14 | 15 | # The exception handler for inertia requests 16 | # ensures that exceptions are returned in interia 17 | # format 18 | EXCEPTION_HANDLER = getattr(settings, 'INERTIA_EXCEPTION_HANDLER', 'drf_inertia.exceptions.DefaultExceptionHandler') 19 | 20 | # The auth redirect is used in the default exception handler 21 | # to determine where to go when 401 or 403 errors are raised 22 | AUTH_REDIRECT = getattr(settings, 'INERTIA_AUTH_REDIRECT', '/login') 23 | 24 | # if AUTH_REDIRECT_URL_NAME is specified use django.urls.reverse 25 | # to get the AUTH_REDIRECT instead 26 | AUTH_REDIRECT_URL_NAME = getattr(settings, 'INERTIA_AUTH_REDIRECT_URL_NAME', None) 27 | 28 | # DEBUG 29 | DEBUG = settings.DEBUG 30 | -------------------------------------------------------------------------------- /drf_inertia/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from .negotiation import Inertia, InertiaNegotiation 4 | from .exceptions import exception_handler 5 | from .config import TEMPLATE, DEBUG 6 | 7 | 8 | def inertia(component_path, template_name=None, **component_kwargs): 9 | """ 10 | Decorator to apply to rest_framework views and viewsets to convert the 11 | request into an interia request / response 12 | 13 | Parameters: 14 | component_path (string): The component that should be passed back to the 15 | frontend for this view. This is the default 16 | component for all 17 | methods. 18 | template_name (string): Optional. override the default template used when 19 | returning HTML 20 | **component_kwargs: Any kwargs passed are used to map class based view 21 | methods or viewset view methods to components e.g. 22 | retrieve="Users/Detail" would ensure that the component 23 | returned for the retrive method would be "Users/Detail" 24 | """ 25 | def decorator(target): 26 | # if this is an api_view then there will be a cls property 27 | # otherwise just need to decorate the target 28 | cls = getattr(target, "cls", target) 29 | 30 | # need to keep the original initial method because we are 31 | # not extending cls, we are replacing the methods, so calling 32 | # super will not work as expected 33 | wrapped_initial = getattr(cls, "initial") 34 | 35 | def initial(self, request, *args, **kwargs): 36 | # Configure Inertia object and add to request 37 | if not hasattr(request, 'inertia'): 38 | # Get the action (~http method) to determine the component. 39 | # ViewSets set the "action" attribute on the instance, 40 | # class based views just use the HTTP method 41 | action = getattr(self, "action", request.method) 42 | cp = component_kwargs.get(action, component_path) 43 | 44 | request.inertia = Inertia.from_request(request, cp) 45 | self.inertia = request.inertia # add to view as convenience 46 | 47 | # Asset Versioning: 48 | # 49 | # must do this after inertia has been added to the request 50 | # otherwise the exception handler will not have access to 51 | # the interia object (which will be on the request) 52 | request.inertia.check_version() 53 | 54 | # set the inertia template 55 | # this can still be overriden by get_template_names() 56 | if not hasattr(self, "template_name") or not self.template_name: 57 | self.template_name = template_name or TEMPLATE 58 | 59 | # call the wrapped initial method 60 | wrapped_initial(self, request, *args, **kwargs) 61 | 62 | def raise_uncaught_exception(self, exc): 63 | if DEBUG: 64 | request = self.request 65 | request.accepted_renderer = 'html' 66 | request.accepted_media_type = "text/html" 67 | raise exc 68 | 69 | # add the updated methods to the cls 70 | cls.get_content_negotiator = lambda self: InertiaNegotiation() 71 | cls.get_exception_handler = lambda self: exception_handler 72 | cls.initial = initial 73 | cls.raise_uncaught_exception = raise_uncaught_exception 74 | return target 75 | return decorator 76 | 77 | 78 | def component(component_path): 79 | """ 80 | Sets the component for a method within a class based view. 81 | 82 | Can only be applied to methods inside APIView classes or ViewSets 83 | 84 | This is a convenience decorator to locate the inertia component 85 | path close to the method it is used with in viewsets or class 86 | based views, instead of specifying all the component paths at 87 | the top of the class. 88 | 89 | The @inertia decorator is still required on the class. 90 | 91 | The @component decorator will always override components set in 92 | the inertia decorator. 93 | ``` 94 | # the following: 95 | @inertia("Users/List", retrieve="Users/Detail") 96 | class UserViewSet(ModelViewSet): 97 | # ... 98 | 99 | # is equivalent to: 100 | 101 | @inertia("Users/List") 102 | class UserViewSet(ModelViewSet): 103 | # ... 104 | @component("Users/Detail") 105 | def retrieve(self, request, *args, **kwargs): 106 | # ... 107 | return Response(serializer.data) 108 | ``` 109 | """ 110 | def method_decorator(method): 111 | @wraps(method) 112 | def wrapper(*args, **kwargs): 113 | # update the request inertia object if it exists 114 | if args[1] and hasattr(args[1], "inertia"): 115 | args[1].inertia.component = component_path 116 | return method(*args, **kwargs) 117 | return wrapper 118 | return method_decorator 119 | -------------------------------------------------------------------------------- /drf_inertia/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.utils.module_loading import import_string 3 | from rest_framework import status, views 4 | from rest_framework.exceptions import ValidationError, APIException, PermissionDenied, NotAuthenticated 5 | 6 | from .config import EXCEPTION_HANDLER, AUTH_REDIRECT, AUTH_REDIRECT_URL_NAME 7 | 8 | 9 | class Conflict(APIException): 10 | status_code = status.HTTP_409_CONFLICT 11 | default_detail = 'Asset version conflict.' 12 | default_code = 'conflict' 13 | 14 | def __init__(self, detail=None, code=None, available_renderers=None): 15 | self.available_renderers = available_renderers 16 | super().__init__(detail, code) 17 | 18 | 19 | class DefaultExceptionHandler(object): 20 | 21 | def get_redirect_status(self, request): 22 | if request.method.lower() in ["put", "patch", "delete"]: 23 | return status.HTTP_303_SEE_OTHER 24 | 25 | return status.HTTP_302_FOUND 26 | 27 | def get_auth_redirect(self): 28 | if AUTH_REDIRECT_URL_NAME: 29 | return reverse(AUTH_REDIRECT_URL_NAME) 30 | 31 | return AUTH_REDIRECT 32 | 33 | def handle(self, exc, context): 34 | override_status = None 35 | override_headers = {} 36 | 37 | request = context["request"] 38 | is_inertia = hasattr(request, "inertia") 39 | 40 | if is_inertia and isinstance(exc, ValidationError): 41 | # redirect user to the error redirect for this page (default is current page) 42 | override_headers["Location"] = request.inertia.get_error_redirect(request) 43 | override_status = self.get_redirect_status(request) 44 | 45 | if is_inertia and (isinstance(exc, PermissionDenied) or isinstance(exc, NotAuthenticated)): 46 | # redirect to the AUTH_REDIRECT 47 | override_status = self.get_redirect_status(request) 48 | override_headers["Location"] = self.get_auth_redirect() 49 | 50 | # use rest framework exception handler to get the response 51 | # this will only catch APIException, 404, or PermissionDenied 52 | response = views.exception_handler(exc, context) 53 | 54 | if not response: 55 | # If here there is an unintended exception (e.g. syntax error) and 56 | # django should handle it. 57 | return 58 | 59 | if override_status: 60 | response.status_code = override_status 61 | if response.data: 62 | # add the errors to the users session 63 | request.session["errors"] = response.data 64 | 65 | if is_inertia and response.status_code == status.HTTP_409_CONFLICT: 66 | response['X-Inertia-Location'] = request.path 67 | 68 | for header in override_headers: 69 | response[header] = override_headers[header] 70 | 71 | return response 72 | 73 | 74 | def exception_handler(exc, context): 75 | handler = import_string(EXCEPTION_HANDLER) 76 | return handler().handle(exc, context) 77 | 78 | 79 | def set_error_redirect(request, error_redirect): 80 | """ 81 | Convenience method to set the Location redirected 82 | to after an error. Should be used at the top of 83 | views. 84 | 85 | You could call set_error_redirect on the interia 86 | object directly but you need to check that the 87 | inertia object is actually there. 88 | """ 89 | if hasattr(request, "inertia"): 90 | request.inertia.set_error_redirect(error_redirect) 91 | -------------------------------------------------------------------------------- /drf_inertia/negotiation.py: -------------------------------------------------------------------------------- 1 | import json 2 | from rest_framework import status 3 | from rest_framework.renderers import TemplateHTMLRenderer, JSONRenderer 4 | from rest_framework.negotiation import DefaultContentNegotiation 5 | 6 | from .config import VERSION, TEMPLATE_VAR 7 | from .serializers import InertiaSerializer 8 | from .exceptions import Conflict 9 | 10 | 11 | REDIRECTS = [ 12 | status.HTTP_301_MOVED_PERMANENTLY, 13 | status.HTTP_302_FOUND, 14 | status.HTTP_303_SEE_OTHER, 15 | ] 16 | 17 | 18 | def is_valid_inertia_response(status_code): 19 | return status_code == status.HTTP_409_CONFLICT or status_code < 300 20 | 21 | 22 | class Inertia(object): 23 | is_data = False # is the X-Inertia header present 24 | version = None 25 | component = None 26 | partial_data = None 27 | url = None 28 | data = {} 29 | _error_redirect = None 30 | 31 | def __init__(self, **kwargs): 32 | for k in kwargs: 33 | if hasattr(self, k): 34 | setattr(self, k, kwargs[k]) 35 | 36 | def include(self, name): 37 | if not self.partial_data: 38 | return True 39 | 40 | return name in self.partial_data 41 | 42 | def __str__(self): 43 | return str(self.__dict__) 44 | 45 | def check_version(self): 46 | # if this is an X-Inertia: true request, and the versions match 47 | if self.is_data and self.version is not None and self.version != VERSION: 48 | # this will trigger a refresh on the frontend 49 | # see https://inertiajs.com/the-protocol#asset-versioning 50 | raise Conflict() 51 | 52 | def set_error_redirect(self, path): 53 | self._error_redirect = path 54 | 55 | def get_error_redirect(self, request): 56 | return self._error_redirect or self.url 57 | 58 | @classmethod 59 | def from_request(cls, request, component): 60 | inertia = Inertia() 61 | inertia.is_data = request.META.get('HTTP_X_INERTIA', False) 62 | inertia.component = component 63 | inertia.url = request.path 64 | inertia.version = request.META.get('HTTP_X_INERTIA_VERSION', None) 65 | 66 | if inertia.is_data: 67 | # if this is an X-Inertia: true request, check the version 68 | if inertia.version is not None and inertia.version != VERSION: 69 | raise Conflict() 70 | 71 | # set partial details if they exist and are valid 72 | partial_component = request.META.get('HTTP_X_INERTIA_PARTIAL_COMPONENT', None) 73 | partial_data = request.META.get('HTTP_X_INERTIA_PARTIAL_DATA', None) 74 | if partial_data and partial_component == component: 75 | inertia.partial_data = [s.strip() for s in partial_data.split(',')] 76 | 77 | return inertia 78 | 79 | 80 | class InertiaRendererMixin(object): 81 | def render(self, data, accepted_media_type=None, renderer_context=None): 82 | # only add data to response if not a redirect 83 | if renderer_context["response"] and renderer_context["response"].status_code not in REDIRECTS: 84 | # add the data to the inertia object then serialize it 85 | # with the InertiaSerializer 86 | renderer_context["request"].inertia.data = data 87 | serializer = InertiaSerializer(renderer_context["request"].inertia, context=renderer_context) 88 | data = serializer.data 89 | 90 | # add response headers 91 | renderer_context["response"]["X-Inertia-Version"] = VERSION 92 | 93 | # Only add X-Inertia header on 2XX and 409 responses 94 | if is_valid_inertia_response(renderer_context["response"].status_code): 95 | renderer_context["response"]["X-Inertia"] = "true" 96 | 97 | return super(InertiaRendererMixin, self).render( 98 | data, accepted_media_type=accepted_media_type, renderer_context=renderer_context) 99 | 100 | 101 | class InertiaHTMLRenderer(InertiaRendererMixin, TemplateHTMLRenderer): 102 | def get_template_context(self, data, renderer_context): 103 | context = super(InertiaHTMLRenderer, self).get_template_context(data, renderer_context) 104 | 105 | # add the inertia data as json into the template 106 | context[TEMPLATE_VAR] = json.dumps(data) 107 | return context 108 | 109 | 110 | class InertiaJSONRenderer(InertiaRendererMixin, JSONRenderer): 111 | pass 112 | 113 | 114 | class InertiaNegotiation(DefaultContentNegotiation): 115 | 116 | def select_renderer(self, request, renderers, format_suffix=None): 117 | # check for inertia headers: 118 | if hasattr(request, 'inertia') and request.inertia.is_data: 119 | renderer = InertiaJSONRenderer() 120 | media_type = "application/json" 121 | else: 122 | # select the default renderer (could be JSON) 123 | # this allows calling the API without the inertia wrapper if necessary 124 | renderer, media_type = super(InertiaNegotiation, self).select_renderer( 125 | request, renderers, format_suffix=format_suffix) 126 | 127 | # once we have the renderer, check media_type and use the 128 | # inertia renderer if the media_type is html 129 | if "html" in media_type: 130 | renderer = InertiaHTMLRenderer() 131 | 132 | return (renderer, media_type) 133 | -------------------------------------------------------------------------------- /drf_inertia/serializers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from django.contrib import messages 3 | from django.utils.module_loading import import_string 4 | from rest_framework import serializers, fields, status 5 | 6 | from .config import SHARED_DATA_SERIALIZER 7 | 8 | 9 | class SharedSerializerBase(serializers.Serializer): 10 | """ 11 | SharedSerializerBase is used to include common data across 12 | requests in each inertia response. 13 | 14 | You can define your own SharedSerializer by setting the 15 | INERTIA_SHARED_SERIALIZER_CLASS in your settings. 16 | 17 | Each SharedSerializer receives an Inertia as the 18 | instance to be "serialized" as well as the render_context 19 | as its context. 20 | 21 | The SharedSerializer serializes the Request by merging 22 | its own fields with the data on the Inertia. Data from 23 | the Inertia is never overwritten by the SharedSerializer. 24 | In this way you can override the default shared data in your 25 | own views if necessary. 26 | 27 | Since the SharedSerializer is used for every Inertia response 28 | you should avoid long running operations and always return 29 | from methods as soon as possible. 30 | 31 | """ 32 | def __init__(self, instance=None, *args, **kwargs): 33 | # exclude fields already in data or not in instance.partial_data 34 | exclude = instance.inertia.data.keys() 35 | for field in self.fields: 36 | if instance.inertia.partial_data and field not in instance.inertia.partial_data: 37 | exclude.append(field) 38 | 39 | for field in exclude: 40 | if field in self.fields: 41 | self.fields.pop(field) 42 | 43 | super(SharedSerializerBase, self).__init__(instance, *args, **kwargs) 44 | 45 | def to_representation(self, instance): 46 | # merge the shared data with the component data 47 | # ensuring that component data is always prioritized 48 | data = super(SharedSerializerBase, self).to_representation(instance) 49 | data.update(instance.inertia.data) 50 | return data 51 | 52 | 53 | class SharedField(fields.Field): 54 | """ 55 | Shared fields by default are Read-only and require a context 56 | """ 57 | requires_context = True 58 | 59 | def __init__(self, **kwargs): 60 | kwargs['read_only'] = True 61 | super().__init__(**kwargs) 62 | 63 | @property 64 | def is_conflict(self): 65 | return self.context["response"].status_code == status.HTTP_409_CONFLICT 66 | 67 | def get_attribute(self, instance): 68 | return instance 69 | 70 | 71 | class FlashSerializer(SharedField): 72 | def to_representation(self, value): 73 | # no need to iterate (and mark used) messages if 409 response 74 | flash = {} 75 | if not self.is_conflict: 76 | storage = messages.get_messages(self.context["request"]) 77 | for message in storage: 78 | flash[message.level_tag] = message.message 79 | return flash 80 | 81 | 82 | class SessionSerializerField(SharedField): 83 | def __init__(self, session_field, **kwargs): 84 | self.session_field = session_field 85 | super(SessionSerializerField, self).__init__(**kwargs) 86 | 87 | def to_representation(self, value): 88 | if not hasattr(self.context["request"], "session"): 89 | return {} 90 | 91 | if not self.is_conflict and self.session_field in self.context["request"].session: 92 | return self.context["request"].session.pop(self.session_field, None) 93 | 94 | return {} 95 | 96 | 97 | class DefaultSharedSerializer(SharedSerializerBase): 98 | errors = SessionSerializerField("errors", default=OrderedDict(), source='*') 99 | flash = FlashSerializer(default=OrderedDict(), source='*') 100 | 101 | 102 | class InertiaSerializer(serializers.Serializer): 103 | component = serializers.CharField() 104 | props = serializers.SerializerMethodField() 105 | version = serializers.CharField() 106 | url = serializers.URLField() 107 | 108 | def get_props(self, obj): 109 | serializer_class = import_string(SHARED_DATA_SERIALIZER) 110 | serializer = serializer_class(self.context["request"], context=self.context) 111 | return serializer.data 112 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: django-rest-inertia 2 | site_description: A server-side adapter for Inertia https://inertiajs.com/ 3 | repo_url: https://github.com/rojoca/django-rest-inertia 4 | site_dir: html 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rojoca/django-rest-inertia/3e5a623de25ab16da00d0e7013b546f6fe0454c1/pytest.ini -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Minimum Django and REST framework version 2 | Django>=1.10 3 | djangorestframework>=2.4.3 4 | 5 | # Test requirements 6 | pytest-django>=3.9 7 | pytest>=5.0,<5.1 8 | pytest-cov==2.7.1 9 | pytest-mock==3.0.0 10 | flake8==2.2.2 11 | 12 | # wheel for PyPI installs 13 | wheel==0.34.2 14 | 15 | # MkDocs for documentation previews/deploys 16 | mkdocs==0.11.1 17 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import pytest 5 | import sys 6 | import os 7 | import subprocess 8 | 9 | 10 | PYTEST_ARGS = { 11 | 'default': ['tests'], 12 | 'fast': ['tests', '-q'], 13 | } 14 | 15 | FLAKE8_ARGS = ['drf_inertia', 'tests', '--ignore=E501'] 16 | 17 | 18 | sys.path.append(os.path.dirname(__file__)) 19 | 20 | 21 | def exit_on_failure(ret, message=None): 22 | if ret: 23 | sys.exit(ret) 24 | 25 | 26 | def flake8_main(args): 27 | print('Running flake8 code linting') 28 | ret = subprocess.call(['flake8'] + args) 29 | print('flake8 failed' if ret else 'flake8 passed') 30 | return ret 31 | 32 | 33 | def split_class_and_function(string): 34 | class_string, function_string = string.split('.', 1) 35 | return "%s and %s" % (class_string, function_string) 36 | 37 | 38 | def is_function(string): 39 | # `True` if it looks like a test function is included in the string. 40 | return string.startswith('test_') or '.test_' in string 41 | 42 | 43 | def is_class(string): 44 | # `True` if first character is uppercase - assume it's a class name. 45 | return string[0] == string[0].upper() 46 | 47 | 48 | if __name__ == "__main__": 49 | try: 50 | sys.argv.remove('--nolint') 51 | except ValueError: 52 | run_flake8 = True 53 | else: 54 | run_flake8 = False 55 | 56 | try: 57 | sys.argv.remove('--lintonly') 58 | except ValueError: 59 | run_tests = True 60 | else: 61 | run_tests = False 62 | 63 | try: 64 | sys.argv.remove('--fast') 65 | except ValueError: 66 | style = 'default' 67 | else: 68 | style = 'fast' 69 | run_flake8 = False 70 | 71 | if len(sys.argv) > 1: 72 | pytest_args = sys.argv[1:] 73 | first_arg = pytest_args[0] 74 | if first_arg.startswith('-'): 75 | # `runtests.py [flags]` 76 | pytest_args = ['tests'] + pytest_args 77 | elif is_class(first_arg) and is_function(first_arg): 78 | # `runtests.py TestCase.test_function [flags]` 79 | expression = split_class_and_function(first_arg) 80 | pytest_args = ['tests', '-k', expression] + pytest_args[1:] 81 | elif is_class(first_arg) or is_function(first_arg): 82 | # `runtests.py TestCase [flags]` 83 | # `runtests.py test_function [flags]` 84 | pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:] 85 | else: 86 | pytest_args = PYTEST_ARGS[style] 87 | 88 | if run_tests: 89 | exit_on_failure(pytest.main(pytest_args)) 90 | if run_flake8: 91 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 92 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import os 5 | import sys 6 | from setuptools import setup 7 | 8 | 9 | name = 'django-rest-inertia' 10 | package = 'drf_inertia' 11 | description = 'A server-side adapter for Inertia https://inertiajs.com/' 12 | url = 'https://github.com/rojoca/django-rest-inertia' 13 | author = 'Rory Casey' 14 | author_email = 'rory@rojoca.com' 15 | license = 'BSD' 16 | 17 | 18 | def get_version(package): 19 | """ 20 | Return package version as listed in `__version__` in `init.py`. 21 | """ 22 | init_py = open(os.path.join(package, '__init__.py')).read() 23 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", 24 | init_py, re.MULTILINE).group(1) 25 | 26 | 27 | def get_packages(package): 28 | """ 29 | Return root package and all sub-packages. 30 | """ 31 | return [dirpath 32 | for dirpath, dirnames, filenames in os.walk(package) 33 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 34 | 35 | 36 | def get_package_data(package): 37 | """ 38 | Return all files under the root package, that are not in a 39 | package themselves. 40 | """ 41 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 42 | for dirpath, dirnames, filenames in os.walk(package) 43 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 44 | 45 | filepaths = [] 46 | for base, filenames in walk: 47 | filepaths.extend([os.path.join(base, filename) 48 | for filename in filenames]) 49 | return {package: filepaths} 50 | 51 | 52 | version = get_version(package) 53 | 54 | 55 | if sys.argv[-1] == 'publish': 56 | if os.system("pip freeze | grep wheel"): 57 | print("wheel not installed.\nUse `pip install wheel`.\nExiting.") 58 | sys.exit() 59 | os.system("python setup.py sdist upload") 60 | os.system("python setup.py bdist_wheel upload") 61 | print("You probably want to also tag the version now:") 62 | print(" git tag -a {0} -m 'version {0}'".format(version)) 63 | print(" git push --tags") 64 | sys.exit() 65 | 66 | 67 | setup( 68 | name=name, 69 | version=version, 70 | url=url, 71 | license=license, 72 | description=description, 73 | author=author, 74 | author_email=author_email, 75 | packages=get_packages(package), 76 | package_data=get_package_data(package), 77 | install_requires=[], 78 | classifiers=[ 79 | 'Development Status :: 2 - Pre-Alpha', 80 | 'Environment :: Web Environment', 81 | 'Framework :: Django', 82 | 'Intended Audience :: Developers', 83 | 'License :: OSI Approved :: BSD License', 84 | 'Operating System :: OS Independent', 85 | 'Natural Language :: English', 86 | 'Programming Language :: Python :: 2', 87 | 'Programming Language :: Python :: 2.7', 88 | 'Programming Language :: Python :: 3', 89 | 'Programming Language :: Python :: 3.3', 90 | 'Programming Language :: Python :: 3.4', 91 | 'Topic :: Internet :: WWW/HTTP', 92 | ] 93 | ) 94 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rojoca/django-rest-inertia/3e5a623de25ab16da00d0e7013b546f6fe0454c1/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | from django.conf import settings 3 | 4 | settings.configure( 5 | DEBUG_PROPAGATE_EXCEPTIONS=True, 6 | DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': ':memory:'}}, 8 | SITE_ID=1, 9 | SECRET_KEY='not very secret in tests', 10 | USE_I18N=True, 11 | USE_L10N=True, 12 | STATIC_URL='/static/', 13 | ROOT_URLCONF='tests.urls', 14 | TEMPLATE_LOADERS=( 15 | 'django.template.loaders.filesystem.Loader', 16 | 'django.template.loaders.app_directories.Loader', 17 | ), 18 | MIDDLEWARE_CLASSES=( 19 | 'django.middleware.common.CommonMiddleware', 20 | 'django.contrib.sessions.middleware.SessionMiddleware', 21 | 'django.middleware.csrf.CsrfViewMiddleware', 22 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 23 | 'django.contrib.messages.middleware.MessageMiddleware', 24 | ), 25 | INSTALLED_APPS=( 26 | 'django.contrib.auth', 27 | 'django.contrib.contenttypes', 28 | 'django.contrib.sessions', 29 | 'django.contrib.sites', 30 | 'django.contrib.messages', 31 | 'django.contrib.staticfiles', 32 | 33 | 'rest_framework', 34 | 'rest_framework.authtoken', 35 | 'tests', 36 | ), 37 | PASSWORD_HASHERS=( 38 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 39 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 40 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 41 | 'django.contrib.auth.hashers.BCryptPasswordHasher', 42 | 'django.contrib.auth.hashers.MD5PasswordHasher', 43 | 'django.contrib.auth.hashers.CryptPasswordHasher', 44 | ), 45 | ) 46 | 47 | try: 48 | import oauth_provider # NOQA 49 | import oauth2 # NOQA 50 | except ImportError: 51 | pass 52 | else: 53 | settings.INSTALLED_APPS += ( 54 | 'oauth_provider', 55 | ) 56 | 57 | try: 58 | import provider # NOQA 59 | except ImportError: 60 | pass 61 | else: 62 | settings.INSTALLED_APPS += ( 63 | 'provider', 64 | 'provider.oauth2', 65 | ) 66 | 67 | # guardian is optional 68 | try: 69 | import guardian # NOQA 70 | except ImportError: 71 | pass 72 | else: 73 | settings.ANONYMOUS_USER_ID = -1 74 | settings.AUTHENTICATION_BACKENDS = ( 75 | 'django.contrib.auth.backends.ModelBackend', 76 | 'guardian.backends.ObjectPermissionBackend', 77 | ) 78 | settings.INSTALLED_APPS += ( 79 | 'guardian', 80 | ) 81 | 82 | try: 83 | import django 84 | django.setup() 85 | except AttributeError: 86 | pass 87 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rojoca/django-rest-inertia/3e5a623de25ab16da00d0e7013b546f6fe0454c1/tests/models.py -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.test import TestCase 3 | from django.db import models 4 | from rest_framework.decorators import api_view 5 | from rest_framework.response import Response 6 | from rest_framework.test import APIRequestFactory 7 | from rest_framework.views import APIView 8 | from rest_framework.viewsets import GenericViewSet 9 | from rest_framework.exceptions import ValidationError 10 | from rest_framework import serializers 11 | 12 | from drf_inertia.decorators import inertia, component 13 | from drf_inertia.exceptions import set_error_redirect 14 | 15 | 16 | class Action(models.Model): 17 | pass 18 | 19 | 20 | class ErrorSerializer(serializers.Serializer): 21 | good_field = serializers.CharField(max_length=1000) 22 | bad_field = serializers.CharField(max_length=5) 23 | 24 | 25 | class DecoratorTestCase(TestCase): 26 | 27 | def setUp(self): 28 | self.factory = APIRequestFactory() 29 | 30 | def _finalize_response(self, request, response, *args, **kwargs): 31 | response.request = request 32 | return APIView.finalize_response(self, request, response, *args, **kwargs) 33 | 34 | def test_api_view_decorated(self): 35 | """ 36 | Ensure adding inertia decorator to api_view works 37 | """ 38 | @inertia("Component/Path") 39 | @api_view(["GET"]) 40 | def view(request): 41 | return Response(data={}) 42 | 43 | request = self.factory.get('/', HTTP_X_INERTIA=True) 44 | response = view(request) 45 | data = json.loads(response.rendered_content) 46 | assert "component" in data 47 | assert "props" in data 48 | assert "url" in data 49 | assert "version" in data 50 | assert data["component"] == "Component/Path" 51 | assert response.status_code == 200 52 | assert response['Content-Type'] == "application/json" 53 | assert response['X-Inertia'] == "true" 54 | 55 | def test_api_view_class_decorated(self): 56 | @inertia("Component/Path") 57 | class TestView(APIView): 58 | def get(self, request, **kwargs): 59 | return Response(data={}) 60 | 61 | request = self.factory.get('/', HTTP_X_INERTIA=True) 62 | response = TestView.as_view()(request) 63 | data = json.loads(response.rendered_content) 64 | assert "component" in data 65 | assert "props" in data 66 | assert "url" in data 67 | assert "version" in data 68 | assert data["component"] == "Component/Path" 69 | assert response.status_code == 200 70 | assert response['Content-Type'] == "application/json" 71 | assert response['X-Inertia'] == "true" 72 | 73 | def test_component_method_decorated(self): 74 | @inertia("Component/Path") 75 | class TestView(APIView): 76 | @component("Component/Other") 77 | def get(self, request, **kwargs): 78 | return Response(data={}) 79 | 80 | request = self.factory.get('/', HTTP_X_INERTIA=True) 81 | response = TestView.as_view()(request) 82 | data = json.loads(response.rendered_content) 83 | assert "component" in data 84 | assert "props" in data 85 | assert "url" in data 86 | assert "version" in data 87 | assert data["component"] == "Component/Other" 88 | assert response.status_code == 200 89 | assert response['Content-Type'] == "application/json" 90 | assert response['X-Inertia'] == "true" 91 | 92 | def test_viewset_decorated(self): 93 | @inertia("Action/List") 94 | class ActionViewSet(GenericViewSet): 95 | queryset = Action.objects.all() 96 | 97 | def list(self, request, *args, **kwargs): 98 | response = Response(data={"view": "list"}) 99 | return response 100 | 101 | request = self.factory.get('/', HTTP_X_INERTIA=True) 102 | response = ActionViewSet.as_view({'get': 'list'})(request) 103 | data = json.loads(response.rendered_content) 104 | assert "component" in data 105 | assert "props" in data 106 | assert "url" in data 107 | assert "version" in data 108 | assert data["component"] == "Action/List" 109 | assert data["props"]["view"] == "list" 110 | assert response.status_code == 200 111 | assert response['Content-Type'] == "application/json" 112 | assert response['X-Inertia'] == "true" 113 | 114 | def test_viewset_decorated_diff(self): 115 | @inertia("Action/List", list="Action/List2") 116 | class ActionViewSet(GenericViewSet): 117 | queryset = Action.objects.all() 118 | 119 | def list(self, request, *args, **kwargs): 120 | response = Response(data={"view": "list"}) 121 | return response 122 | 123 | request = self.factory.get('/', HTTP_X_INERTIA=True) 124 | response = ActionViewSet.as_view({'get': 'list'})(request) 125 | data = json.loads(response.rendered_content) 126 | assert "component" in data 127 | assert "props" in data 128 | assert "url" in data 129 | assert "version" in data 130 | assert data["component"] == "Action/List2" 131 | assert data["props"]["view"] == "list" 132 | assert response.status_code == 200 133 | assert response['Content-Type'] == "application/json" 134 | assert response["X-Inertia"] == "true" 135 | 136 | def test_decorated_api_view_handles_error(self): 137 | @inertia("Component/Path") 138 | class TestView(APIView): 139 | def get(self, request, **kwargs): 140 | raise ValidationError("This is an error") 141 | 142 | request = self.factory.get('/', HTTP_X_INERTIA=True) 143 | request.session = {} 144 | response = TestView.as_view()(request) 145 | assert response.status_code == 302 146 | assert "errors" in request.session 147 | assert response["Location"] == "/" 148 | 149 | def test_decorated_api_view_handles_serializer_error(self): 150 | @inertia("Component/Path") 151 | class TestView(APIView): 152 | def get(self, request, **kwargs): 153 | s = ErrorSerializer(data={"good_field": "test", "bad_field": "really long"}) 154 | s.is_valid(raise_exception=True) 155 | 156 | request = self.factory.get('/', HTTP_X_INERTIA=True) 157 | request.session = {} 158 | response = TestView.as_view()(request) 159 | assert response.status_code == 302 160 | assert "errors" in request.session 161 | assert "bad_field" in request.session["errors"] 162 | assert response["Location"] == "/" 163 | 164 | def test_decorated_api_view_set_error_redirect(self): 165 | @inertia("Component/Path") 166 | class TestView(APIView): 167 | def get(self, request, **kwargs): 168 | set_error_redirect(request, "/error/redirect") 169 | raise ValidationError("This is an error") 170 | 171 | request = self.factory.get('/', HTTP_X_INERTIA=True) 172 | request.session = {} 173 | response = TestView.as_view()(request) 174 | assert response.status_code == 302 175 | assert "errors" in request.session 176 | assert response["Location"] == "/error/redirect" 177 | -------------------------------------------------------------------------------- /tests/test_negotiation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import TestCase 3 | 4 | from rest_framework.request import Request 5 | from rest_framework.test import APIRequestFactory 6 | from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer 7 | 8 | from drf_inertia.negotiation import Inertia, InertiaNegotiation, InertiaJSONRenderer, InertiaHTMLRenderer 9 | from drf_inertia.exceptions import Conflict 10 | 11 | factory = APIRequestFactory() 12 | 13 | 14 | class MockInertia(Inertia): 15 | is_data = True 16 | component = "Test/Component" 17 | url = "/" 18 | version = "unversioned" 19 | 20 | 21 | class TestInertia(TestCase): 22 | def test_from_request_is_data_true(self): 23 | request = Request(factory.get('/', HTTP_X_INERTIA=True)) 24 | inertia = Inertia.from_request(request, "Component/Path") 25 | assert inertia.is_data 26 | assert inertia.component == "Component/Path" 27 | assert inertia.url == "/" 28 | 29 | def test_from_request_is_data_false(self): 30 | request = Request(factory.get('/')) 31 | inertia = Inertia.from_request(request, "Component/Path") 32 | assert inertia.is_data is False 33 | 34 | def test_from_request_partial_data(self): 35 | component = "Component/Path" 36 | request = Request(factory.get( 37 | '/', 38 | HTTP_X_INERTIA=True, 39 | HTTP_X_INERTIA_PARTIAL_DATA='prop1,prop2', 40 | HTTP_X_INERTIA_PARTIAL_COMPONENT=component)) 41 | inertia = Inertia.from_request(request, component) 42 | assert inertia.is_data 43 | assert inertia.partial_data == ['prop1', 'prop2'] 44 | 45 | def test_from_request_raises_conflict(self): 46 | request = Request(factory.get('/', HTTP_X_INERTIA=True, HTTP_X_INERTIA_VERSION="1.2.4")) 47 | with pytest.raises(Conflict): 48 | Inertia.from_request(request, "Component/Path") 49 | 50 | def test_from_request_matching_version_does_not_raise_conflict(self): 51 | request = Request(factory.get('/', HTTP_X_INERTIA=True, HTTP_X_INERTIA_VERSION="unversioned")) 52 | try: 53 | Inertia.from_request(request, "Component/Path") 54 | except Conflict: 55 | pytest.fail("Matching inertia version raises Conflict") 56 | 57 | 58 | class TestInertiaNegotiation(TestCase): 59 | def setUp(self): 60 | self.negotiator = InertiaNegotiation() 61 | self.renderers = [JSONRenderer(), TemplateHTMLRenderer()] 62 | 63 | def select_renderer(self, request): 64 | return self.negotiator.select_renderer(request, self.renderers) 65 | 66 | def test_inertia_request_selects_json_renderer(self): 67 | request = Request(factory.get('/', HTTP_X_INERTIA=True)) 68 | request.inertia = Inertia.from_request(request, "Component/Path") 69 | renderer, media_type = self.select_renderer(request) 70 | assert media_type == "application/json" 71 | assert isinstance(renderer, InertiaJSONRenderer) 72 | 73 | def test_json_request_no_inertia(self): 74 | request = Request(factory.get('/', HTTP_ACCEPT="application/json")) 75 | request.inertia = Inertia.from_request(request, "Component/Path") 76 | renderer, media_type = self.select_renderer(request) 77 | assert media_type == "application/json" 78 | assert isinstance(renderer, JSONRenderer) 79 | assert isinstance(renderer, InertiaJSONRenderer) is False 80 | 81 | def test_html_request_selects_inertia_html_renderer(self): 82 | request = Request(factory.get('/', HTTP_ACCEPT="text/html")) 83 | request.inertia = Inertia.from_request(request, "Component/Path") 84 | renderer, media_type = self.select_renderer(request) 85 | assert media_type == "text/html" 86 | assert isinstance(renderer, InertiaHTMLRenderer) 87 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py35-{flake8,docs}, 4 | {py35,py37}-django{1.11,2.2,3.1}-drf{2.4,3.0,3.1} 5 | 6 | [testenv] 7 | commands = ./runtests.py --fast 8 | setenv = 9 | PYTHONDONTWRITEBYTECODE=1 10 | deps = 11 | django1.6: Django==1.11 12 | django1.7: Django==2.2 13 | django1.8: Django==3.0 14 | drf2.4: djangorestframework==2.4.4 15 | drf3.0: djangorestframework==3.0.5 16 | drf3.1: djangorestframework==3.1.3 17 | pytest-django==3.6.0 18 | 19 | [testenv:py27-flake8] 20 | commands = ./runtests.py --lintonly 21 | deps = 22 | pytest==3.9.0 23 | flake8==2.4.0 24 | 25 | [testenv:py27-docs] 26 | commands = mkdocs build 27 | deps = 28 | mkdocs>=0.11.1 29 | --------------------------------------------------------------------------------