Render components as views (#366) (thanks @dylanjcastillo)

This commit is contained in:
Dylan Castillo 2024-01-24 22:36:57 +01:00 committed by GitHub
parent b29e7fba80
commit 91b4accfeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 347 additions and 15 deletions

View file

@ -20,6 +20,8 @@ Read on to learn about the details!
## Release notes
*Version 0.34* adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#components-as-views) for more details.
*Version 0.28* introduces 'implicit' slot filling and the `default` option for `slot` tags.
*Version 0.27* adds a second installable app: *django_components.safer_staticfiles*. It provides the same behavior as *django.contrib.staticfiles* but with extra security guarantees (more info below in Security Notes).
@ -403,6 +405,72 @@ This is fine too:
{% endcomponent_block %}
```
### Components as views
_New in version 0.34_
Components can now be used as views. To do this, `Component` subclasses Django's `View` class. This means that you can use all of the [methods](https://docs.djangoproject.com/en/5.0/ref/class-based-views/base/#view) of `View` in your component. For example, you can override `get` and `post` to handle GET and POST requests, respectively.
In addition, `Component` now has a `render_to_response` method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object.
Here's an example of a calendar component defined as a view:
```python
# In a file called [project root]/components/calendar.py
from django_components import component
@component.register("calendar")
class Calendar(component.Component):
template = """
<div class="calendar-component">
<div class="header">
{% slot "header" %}{% endslot %}
</div>
<div class="body">
Today's date is <span>{{ date }}</span>
</div>
</div>
"""
def get(self, request, *args, **kwargs):
context = {
"date": request.GET.get("date", "2020-06-06"),
}
slots = {
"header": "Calendar header",
}
return self.render_to_response(context, slots)
```
Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view:
```python
# In a file called [project root]/components/urls.py
from django.urls import path
from calendar import Calendar
urlpatterns = [
path("calendar/", Calendar.as_view()),
]
```
Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file.
Finally, include the component's urls in your project's `urls.py` file:
```python
# In a file called [project root]/urls.py
from django.urls import include, path
urlpatterns = [
path("components/", include("components.urls")),
]
```
Note: slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe).
### Advanced

View file

@ -4,11 +4,14 @@ from typing import Any, ClassVar, Dict, Iterable, Optional, Set, Tuple, Union
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media, MediaDefiningClass
from django.template.base import NodeList, Template
from django.http import HttpResponse
from django.template.base import NodeList, Template, TextNode
from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError
from django.template.loader import get_template
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.views import View
# Global registry var and register() function moved to separate module.
# Defining them here made little sense, since 1) component_tags.py and component.py
@ -59,7 +62,7 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
return super().__new__(mcs, name, bases, attrs)
class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return.
template_name: ClassVar[Optional[str]] = None
@ -84,6 +87,11 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content
@classmethod
@property
def class_hash(cls):
return hash(str(cls.__module__) + str(cls.__name__))
def get_context_data(self, *args, **kwargs) -> Dict[str, Any]:
return {}
@ -133,8 +141,18 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
)
def render(self, context):
def render(
self,
context_data: Dict[str, Any],
slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: Optional[bool] = True,
) -> str:
context = Context(context_data)
template = self.get_template(context)
if slots_data:
self._fill_slots(slots_data, escape_slots_content)
updated_filled_slots_context: FilledSlotsContext = (
self._process_template_and_update_filled_slot_context(
context, template
@ -145,8 +163,39 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
):
return template.render(context)
def render_to_response(
self,
context_data: Dict[str, Any],
slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: Optional[bool] = True,
*args,
**kwargs,
):
return HttpResponse(
self.render(context_data, slots_data, escape_slots_content),
*args,
**kwargs,
)
def _fill_slots(
self,
slots_data: Dict[SlotName, str],
escape_content: bool,
):
"""Fill component slots outside of template rendering."""
self.fill_content = [
(
slot_name,
TextNode(escape(content) if escape_content else content),
None,
)
for (slot_name, content) in slots_data.items()
]
def _process_template_and_update_filled_slot_context(
self, context: Context, template: Template
self,
context: Context,
template: Template,
) -> FilledSlotsContext:
if isinstance(self.fill_content, NodeList):
default_fill_content = (self.fill_content, None)

View file

@ -11,11 +11,14 @@ class ComponentRegistry(object):
self._registry = {} # component name -> component_class mapping
def register(self, name=None, component=None):
if name in self._registry:
existing_component = self._registry.get(name)
if (
existing_component
and existing_component.class_hash != component.class_hash
):
raise AlreadyRegistered(
'The component "%s" is already registered' % name
'The component "%s" has already been registered' % name
)
self._registry[name] = component
def unregister(self, name):

View file

@ -6,7 +6,11 @@
</head>
<body>
{% component "calendar" date=date %}
{% component "greeting" greet='Hello world' %}
{% component_block "greeting" name='Joe' %}
{% fill "message" %}
Howdy?
{% endfill %}
{% endcomponent_block %}
{% component_js_dependencies %}
</body>
</html>

View file

View file

@ -1,13 +1,21 @@
from typing import Any, Dict
from django_components import component
@component.register("greeting")
class greeting(component.Component):
def get_context_data(self, greet, *args, **kwargs):
return {"greet": greet}
class Greeting(component.Component):
def get(self, request, *args, **kwargs):
slots = {"message": "Hello, world!"}
context = {"name": request.GET.get("name", "")}
return self.render_to_response(context, slots)
def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]:
return {"name": name}
template = """
<div id="greeting">{{ greet }}</div>
<div id="greeting">Hello, {{ name }}!</div>
{% slot "message" %}{% endslot %}
"""
css = """

View file

@ -0,0 +1,6 @@
from django.urls import path
from greeting import Greeting
urlpatterns = [
path("greeting/", Greeting.as_view(), name="greeting"),
]

View file

@ -2,4 +2,5 @@ from django.urls import include, path
urlpatterns = [
path("", include("calendarapp.urls")),
path("", include("components.urls")),
]

View file

@ -0,0 +1,173 @@
from typing import Any, Dict
from django.http import HttpResponse
from django.template import Context, Template
from django.test import Client
from django.urls import include, path
# isort: off
from .django_test_setup import * # noqa
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
# isort: on
from django_components import component
@component.register("testcomponent")
class MockComponentRequest(component.Component):
template = """
<form method="post">
{% csrf_token %}
<input type="text" name="variable" value="{{ variable }}">
<input type="submit">
</form>
"""
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return self.render_to_response({"variable": variable})
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"})
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}
@component.register("testcomponent_slot")
class MockComponentSlot(component.Component):
template = """
{% load component_tags %}
<div>
{% slot "first_slot" %}
Hey, I'm {{ name }}
{% endslot %}
{% slot "second_slot" %}
{% endslot %}
</div>
"""
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response(
{"name": "Bob"}, {"second_slot": "Nice to meet you, Bob"}
)
@component.register("testcomponent_context_insecure")
class MockInsecureComponentContext(component.Component):
template = """
{% load component_tags %}
<div>
{{ variable }}
</div>
"""
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response(
{"variable": "<script>alert(1);</script>"}
)
@component.register("testcomponent_slot_insecure")
class MockInsecureComponentSlot(component.Component):
template = """
{% load component_tags %}
<div>
{% slot "test_slot" %}
{% endslot %}
</div>
"""
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response(
{}, {"test_slot": "<script>alert(1);</script>"}
)
def render_template_view(request):
template = Template(
"""
{% load component_tags %}
{% component "testcomponent" variable="TEMPLATE" %}
"""
)
return HttpResponse(template.render(Context({})))
components_urlpatterns = [
path("test/", MockComponentRequest.as_view()),
path("test_slot/", MockComponentSlot.as_view()),
path("test_context_insecure/", MockInsecureComponentContext.as_view()),
path("test_slot_insecure/", MockInsecureComponentSlot.as_view()),
path("test_template/", render_template_view),
]
urlpatterns = [
path("", include(components_urlpatterns)),
]
class CustomClient(Client):
def __init__(self, *args, **kwargs):
settings.ROOT_URLCONF = __name__ # noqa
settings.SECRET_KEY = "secret" # noqa
super().__init__(*args, **kwargs)
class TestComponentAsView(SimpleTestCase):
def setUp(self):
self.client = CustomClient()
def test_render_component_from_template(self):
response = self.client.get("/test_template/")
self.assertEqual(response.status_code, 200)
self.assertIn(
b'<input type="text" name="variable" value="TEMPLATE">',
response.content,
)
def test_get_request(self):
response = self.client.get("/test/")
self.assertEqual(response.status_code, 200)
self.assertIn(
b'<input type="text" name="variable" value="GET">',
response.content,
)
def test_post_request(self):
response = self.client.post("/test/", {"variable": "POST"})
self.assertEqual(response.status_code, 200)
self.assertIn(
b'<input type="text" name="variable" value="POST">',
response.content,
)
def test_replace_slot_in_view(self):
response = self.client.get("/test_slot/")
self.assertEqual(response.status_code, 200)
self.assertIn(
b"Hey, I'm Bob",
response.content,
)
self.assertIn(
b"Nice to meet you, Bob",
response.content,
)
def test_replace_slot_in_view_with_insecure_content(self):
response = self.client.get("/test_slot_insecure/")
self.assertEqual(response.status_code, 200)
self.assertNotIn(
b"<script>",
response.content,
)
def test_replace_context_in_view_with_insecure_content(self):
response = self.client.get("/test_slot_insecure/")
self.assertEqual(response.status_code, 200)
self.assertNotIn(
b"<script>",
response.content,
)

View file

@ -5,10 +5,19 @@ from django_components import component
from .django_test_setup import * # NOQA
class MockComponent(object):
class MockComponent(component.Component):
pass
class MockComponent2(component.Component):
pass
class MockComponentView(component.Component):
def get(self, request, *args, **kwargs):
pass
class ComponentRegistryTest(unittest.TestCase):
def setUp(self):
self.registry = component.ComponentRegistry()
@ -37,13 +46,24 @@ class ComponentRegistryTest(unittest.TestCase):
},
)
def test_prevent_registering_twice(self):
def test_prevent_registering_different_components_with_the_same_name(self):
self.registry.register(name="testcomponent", component=MockComponent)
with self.assertRaises(component.AlreadyRegistered):
self.registry.register(
name="testcomponent", component=MockComponent
name="testcomponent", component=MockComponent2
)
def test_allow_duplicated_registration_of_the_same_component(self):
try:
self.registry.register(
name="testcomponent", component=MockComponentView
)
self.registry.register(
name="testcomponent", component=MockComponentView
)
except component.AlreadyRegistered:
self.fail("Should not raise AlreadyRegistered")
def test_simple_unregister(self):
self.registry.register(name="testcomponent", component=MockComponent)
self.registry.unregister(name="testcomponent")