Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Serve components view #25

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
50 changes: 49 additions & 1 deletion laces/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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,
Expand Down Expand Up @@ -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
41 changes: 40 additions & 1 deletion laces/test/example/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
72 changes: 72 additions & 0 deletions laces/test/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
"<h1>Hello World</h1>",
)

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"),
"<h1>Hello Alice</h1>",
)

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"),
"<h1>Hello Bob</h1>",
)

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)
3 changes: 2 additions & 1 deletion laces/test/urls.py
Original file line number Diff line number Diff line change
@@ -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")),
]
74 changes: 73 additions & 1 deletion laces/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions laces/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import path

from laces.views import serve


app_name = "laces"


urlpatterns = [path("<slug:component_slug>/", serve, name="serve")]
30 changes: 30 additions & 0 deletions laces/views.py
Original file line number Diff line number Diff line change
@@ -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())
Loading