diff --git a/laces/components.py b/laces/components.py
index 918a19b..916e2d2 100644
--- a/laces/components.py
+++ b/laces/components.py
@@ -8,12 +8,15 @@
if TYPE_CHECKING:
- from typing import Optional
+ from typing import Callable, Optional, Type, TypeVar
+ from django.http import HttpRequest
from django.utils.safestring import SafeString
from laces.typing import RenderContext
+ T = TypeVar("T", bound="Component")
+
class Component(metaclass=MediaDefiningClass):
"""
@@ -30,6 +33,22 @@ class Component(metaclass=MediaDefiningClass):
template_name: str
+ @classmethod
+ def from_request(cls: "Type[T]", request: "HttpRequest", /) -> "T":
+ """
+ Create an instance of this component based on the given request.
+
+ This method is mostly an extension point to add custom logic. If a component has
+ specific access controls, this would be a good spot to check them.
+
+ By default, the request's querystring parameters are passed as keyword arguments
+ to the default initializer. No type conversion is applied. This means that the
+ initializer receives all arguments as strings. To change that behavior, override
+ this method.
+ """
+ kwargs = request.GET.dict()
+ return cls(**kwargs)
+
def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
@@ -104,3 +123,32 @@ def media(self) -> Media:
for item in self:
media += item.media
return media
+
+
+_servables = {}
+
+
+def register_servable(name: str) -> "Callable[[type[Component]], type[Component]]":
+ def decorator(component_class: type[Component]) -> type[Component]:
+ _servables[name] = component_class
+ return component_class
+
+ return decorator
+
+
+class ServableComponentNotFound(Exception):
+ def __init__(self, slug: str) -> None:
+ self.name = slug
+ super().__init__(self.get_message())
+
+ def get_message(self) -> str:
+ return f"No servable component '{self.name}' found."
+
+
+def get_servable(slug: str) -> type[Component]:
+ try:
+ component_class = _servables[slug]
+ except KeyError:
+ raise ServableComponentNotFound(slug=slug)
+ else:
+ return component_class
diff --git a/laces/test/example/components.py b/laces/test/example/components.py
index 3702159..9001a9b 100644
--- a/laces/test/example/components.py
+++ b/laces/test/example/components.py
@@ -10,7 +10,7 @@
from django.utils.html import format_html
-from laces.components import Component
+from laces.components import Component, register_servable
if TYPE_CHECKING:
@@ -199,3 +199,42 @@ def render_html(
class Media:
css = {"all": ("footer.css",)}
js = ("footer.js", "common.js")
+
+
+# Servables
+
+
+@register_servable("fixed-content-template")
+class ServableWithFixedContentTemplateComponent(Component):
+ template_name = "components/hello-world.html"
+
+
+@register_servable("with-init-args")
+class ServableWithInitilizerArgumentsComponent(Component):
+ template_name = "components/hello-name.html"
+
+ def __init__(self, name: str) -> None:
+ super().__init__()
+ self.name = name
+
+ def get_context_data(
+ self,
+ parent_context: "Optional[RenderContext]" = None,
+ ) -> "RenderContext":
+ return {"name": self.name}
+
+
+@register_servable("int-adder")
+class ServableIntAdderComponent(Component):
+ def __init__(self, number: int) -> None:
+ self.number = 0 + number
+
+
+class CustomException(Exception):
+ pass
+
+
+@register_servable("with-custom-exception-init")
+class ServableWithCustomExceptionInitializerComponent(Component):
+ def __init__(self) -> None:
+ raise CustomException
diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py
index caa1fed..a124090 100644
--- a/laces/test/tests/test_views.py
+++ b/laces/test/tests/test_views.py
@@ -97,3 +97,75 @@ def test_get(self) -> None:
response_html,
count=1,
)
+
+
+class TestServeComponent(TestCase):
+ """
+ Test the serve view from the perspective of an external project.
+
+ The functionality is not defined in this project. It's in Laces, but I want to have
+ some use cases of how this maybe be used.
+
+ This makes some assumptions about what is set up in the project. There will need to
+ be other more encapsulated tests in Laces directly.
+
+ """
+
+ def test_get_component(self) -> None:
+ # Requesting the `laces.test.example.components.ServableWithWithFixedContentTemplateComponent`
+ response = self.client.get("/components/fixed-content-template/")
+
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertHTMLEqual(
+ response.content.decode("utf-8"),
+ "
Hello World
",
+ )
+
+ def test_get_not_registered_component(self) -> None:
+ response = self.client.get("/components/not-a-component/")
+
+ self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
+
+ def test_get_component_with_init_args_and_name_alice(self) -> None:
+ response = self.client.get("/components/with-init-args/?name=Alice")
+
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertHTMLEqual(
+ response.content.decode("utf-8"),
+ "Hello Alice
",
+ )
+
+ def test_get_component_with_init_args_and_name_bob(self) -> None:
+ response = self.client.get("/components/with-init-args/?name=Bob")
+
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertHTMLEqual(
+ response.content.decode("utf-8"),
+ "Hello Bob
",
+ )
+
+ def test_get_component_with_init_args_and_name_and_extra_parameter(self) -> None:
+ response = self.client.get(
+ "/components/with-init-args/?name=Bob&extra=notexpected"
+ )
+
+ # You could argue that we should ignore the extra parameters. But, this seems
+ # like it would create inconsistent behavior between having too many and too few
+ # arguments. It's probably cleaner to just response the same and require the
+ # request to be fixed.
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
+
+ def test_get_component_with_init_args_and_no_parameters(self) -> None:
+ response = self.client.get("/components/with-init-args/")
+
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
+
+ def test_get_component_with_non_string_argument(self) -> None:
+ response = self.client.get("/components/int-adder/?number=2")
+
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
+
+ def test_get_component_with_custom_exception(self) -> None:
+ response = self.client.get("/components/with-custom-exception-init/")
+
+ self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
diff --git a/laces/test/urls.py b/laces/test/urls.py
index 85bfc8f..66bd3c8 100644
--- a/laces/test/urls.py
+++ b/laces/test/urls.py
@@ -1,8 +1,9 @@
-from django.urls import path
+from django.urls import include, path
from laces.test.example.views import kitchen_sink
urlpatterns = [
path("", kitchen_sink),
+ path("components/", include("laces.urls")),
]
diff --git a/laces/tests/test_components.py b/laces/tests/test_components.py
index b5bfeaf..1ea703f 100644
--- a/laces/tests/test_components.py
+++ b/laces/tests/test_components.py
@@ -7,7 +7,7 @@
from django.conf import settings
from django.forms import widgets
from django.template import Context
-from django.test import SimpleTestCase
+from django.test import RequestFactory, SimpleTestCase
from django.utils.safestring import SafeString
from laces.components import Component, MediaContainer
@@ -88,10 +88,82 @@ def setUp(self) -> None:
# Write content to the template file to ensure it exists.
self.set_example_template_content("")
+ self.request_factory = RequestFactory()
+
def set_example_template_content(self, content: str) -> None:
with open(self.example_template, "w") as f:
f.write(content)
+ def test_from_request_with_component_wo_init_args(self) -> None:
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ pass
+
+ # -----------------------------------------------------------------------------
+ request = self.request_factory.get("")
+
+ result = ExampleComponent.from_request(request)
+
+ self.assertIsInstance(result, ExampleComponent)
+
+ def test_from_request_with_component_w_name_arg_request_wo_name_para(self) -> None:
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+ # -----------------------------------------------------------------------------
+ request = self.request_factory.get("")
+
+ with self.assertRaises(TypeError) as ctx:
+ ExampleComponent.from_request(request)
+
+ self.assertIn("required positional argument", str(ctx.exception))
+
+ def test_from_request_with_component_w_name_arg_request_w_name_para(self) -> None:
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+ # -----------------------------------------------------------------------------
+ request = self.request_factory.get("", data={"name": "Alice"})
+
+ result = ExampleComponent.from_request(request)
+
+ self.assertEqual(result.name, "Alice")
+
+ def test_from_request_with_component_w_name_arg_request_w_extra_para(self) -> None:
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+ # -----------------------------------------------------------------------------
+ request = self.request_factory.get("", data={"name": "Alice", "other": "Bob"})
+
+ with self.assertRaises(TypeError) as ctx:
+ ExampleComponent.from_request(request)
+
+ self.assertIn("unexpected keyword argument", str(ctx.exception))
+
+ def test_from_request_with_component_init_raises_custom_exception(self) -> None:
+ # -----------------------------------------------------------------------------
+ class CustomException(Exception):
+ pass
+
+ class ExampleComponent(Component):
+ def __init__(self) -> None:
+ raise CustomException
+
+ # -----------------------------------------------------------------------------
+ request = self.request_factory.get("")
+
+ # No special handling happens in the `from_request` method by default.
+ # The raised exception should be exposed.
+ with self.assertRaises(CustomException):
+ ExampleComponent.from_request(request)
+
def test_render_html_with_template_name_set(self) -> None:
"""
Test `render_html` method with a set `template_name` attribute.
diff --git a/laces/urls.py b/laces/urls.py
new file mode 100644
index 0000000..41679d2
--- /dev/null
+++ b/laces/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from laces.views import serve
+
+
+app_name = "laces"
+
+
+urlpatterns = [path("/", serve, name="serve")]
diff --git a/laces/views.py b/laces/views.py
new file mode 100644
index 0000000..4732cb3
--- /dev/null
+++ b/laces/views.py
@@ -0,0 +1,30 @@
+import logging
+
+from typing import TYPE_CHECKING
+
+from django.core.exceptions import BadRequest
+from django.http import Http404, HttpResponse
+
+from laces.components import ServableComponentNotFound, get_servable
+
+
+if TYPE_CHECKING:
+ from django.http import HttpRequest
+
+logger = logging.getLogger(__name__)
+
+
+def serve(request: "HttpRequest", component_slug: str) -> HttpResponse:
+ logger.error(component_slug)
+
+ try:
+ Component = get_servable(component_slug)
+ except ServableComponentNotFound:
+ raise Http404
+
+ try:
+ component = Component.from_request(request)
+ except Exception as e:
+ raise BadRequest(e)
+
+ return HttpResponse(content=component.render_html())