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())