diff --git a/src/selva/web/templates/jinja.py b/src/selva/web/templates/jinja.py index 73f164c..ec89f05 100644 --- a/src/selva/web/templates/jinja.py +++ b/src/selva/web/templates/jinja.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Annotated, Literal, Type, TypeVar -from asgikit.responses import Response, respond_text +from asgikit.responses import Response, respond_stream, respond_text from jinja2 import ( BaseLoader, BytecodeCache, @@ -73,10 +73,32 @@ async def respond( self, response: Response, template_name: str, - context: dict, + context: dict = None, + *, status: HTTPStatus = HTTPStatus.OK, + content_type: str = None, + stream: bool = False, ): - response.content_type = "text/html" + context = context or {} + + if content_type: + response.content_type = content_type + elif not response.content_type: + response.content_type = "text/html" + + template = self.environment.get_template(template_name) + + if stream: + render_stream = template.generate_async(context) + await respond_stream(response, render_stream, status=status) + else: + rendered = await template.render_async(context) + await respond_text(response, rendered, status=status) + + async def render(self, template_name: str, context: dict) -> str: template = self.environment.get_template(template_name) - rendered = await template.render_async(context) - await respond_text(response, rendered, status=status) + return await template.render_async(context) + + async def render_str(self, source: str, context: dict) -> str: + template = self.environment.from_string(source) + return await template.render_async(context) diff --git a/src/selva/web/templates/template.py b/src/selva/web/templates/template.py index 27e8237..3f11102 100644 --- a/src/selva/web/templates/template.py +++ b/src/selva/web/templates/template.py @@ -10,7 +10,18 @@ async def respond( self, response: Response, template_name: str, - context: dict, + context: dict = None, + *, status: HTTPStatus = HTTPStatus.OK, + content_type: str = None, + stream: bool = False, ): - pass + raise NotImplementedError() + + @abstractmethod + async def render(self, template_name: str, context: dict) -> str: + raise NotImplementedError() + + @abstractmethod + async def render_str(self, source: str, context: dict) -> str: + raise NotImplementedError() diff --git a/tests/web/templates/application.py b/tests/web/templates/application.py new file mode 100644 index 0000000..f8c750a --- /dev/null +++ b/tests/web/templates/application.py @@ -0,0 +1,57 @@ +from typing import Annotated + +from selva.di import Inject +from selva.web import controller, get +from selva.web.templates import Template + + +@controller +class Controller: + template: Annotated[Template, Inject] + + @get("/render") + async def render(self, request): + await self.template.respond( + request.response, + "template.html", + {"variable": "Jinja"}, + ) + + @get("/stream") + async def stream(self, request): + await self.template.respond( + request.response, + "template.html", + {"variable": "Jinja"}, + stream=True, + ) + + @get("/define_content_type") + async def define_content_type(self, request): + await self.template.respond( + request.response, + "template.html", + {"variable": "Jinja"}, + content_type="text/defined", + ) + + @get("/override_content_type") + async def override_content_type(self, request): + request.response.content_type = "text/plain" + + await self.template.respond( + request.response, + "template.html", + {"variable": "Jinja"}, + content_type="text/overriden", + ) + + @get("/content_type_from_response") + async def content_type_from_response(self, request): + request.response.content_type = "text/from_response" + + await self.template.respond( + request.response, + "template.html", + {"variable": "Jinja"}, + ) diff --git a/tests/web/templates/template.html b/tests/web/templates/template.html new file mode 100644 index 0000000..72c9210 --- /dev/null +++ b/tests/web/templates/template.html @@ -0,0 +1 @@ +{{ variable }} \ No newline at end of file diff --git a/tests/web/templates/jinja.py b/tests/web/templates/test_jinja.py similarity index 83% rename from tests/web/templates/jinja.py rename to tests/web/templates/test_jinja.py index 1d7fc10..6d1a123 100644 --- a/tests/web/templates/jinja.py +++ b/tests/web/templates/test_jinja.py @@ -1,5 +1,6 @@ import jinja2 import pytest +from pydantic import ValidationError from selva.web.templates.jinja import JinjaTemplateSettings @@ -56,30 +57,31 @@ def test_autoescape_bool(value, expected): def test_invalid_undefined_should_fail(): - with pytest.raises(TypeError): + with pytest.raises(ValidationError): JinjaTemplateSettings.model_validate({"undefined": 1}) def test_invalid_import_undefined_should_fail(): - with pytest.raises(ImportError): + with pytest.raises(ValidationError): JinjaTemplateSettings.model_validate({"undefined": "does.not.exist"}) def test_invalid_finalize_should_fail(): - with pytest.raises(TypeError): + with pytest.raises(ValidationError): JinjaTemplateSettings.model_validate({"finalize": 1}) def test_invalid_import_finalize_should_fail(): - with pytest.raises(ImportError): + with pytest.raises(ValidationError): JinjaTemplateSettings.model_validate({"finalize": "does.not.exist"}) def test_invalid_autoescape_should_fail(): - with pytest.raises(TypeError): - JinjaTemplateSettings.model_validate({"autoescape": 1}) + with pytest.raises(ValidationError): + model = JinjaTemplateSettings.model_validate({"autoescape": "invalid"}) + repr(model) def test_invalid_import_autoescape_should_fail(): - with pytest.raises(ImportError): + with pytest.raises(ValidationError): JinjaTemplateSettings.model_validate({"autoescape": "does.not.exist"}) diff --git a/tests/web/templates/test_jinja_render.py b/tests/web/templates/test_jinja_render.py new file mode 100644 index 0000000..8bd039b --- /dev/null +++ b/tests/web/templates/test_jinja_render.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from selva.configuration.defaults import default_settings +from selva.configuration.settings import Settings +from selva.web.templates.jinja import JinjaTemplate + + +async def test_render_template(): + path = str(Path(__file__).parent.absolute()) + settings = Settings(default_settings | {"templates": {"jinja": {"path": path}}}) + + template = JinjaTemplate(settings) + template.initialize() + result = await template.render("template.html", {"variable": "Jinja"}) + assert result == "Jinja" + + +async def test_render_str(): + settings = Settings(default_settings) + template = JinjaTemplate(settings) + template.initialize() + result = await template.render_str("{{ variable }}", {"variable": "Jinja"}) + assert result == "Jinja" diff --git a/tests/web/templates/test_jinja_response.py b/tests/web/templates/test_jinja_response.py new file mode 100644 index 0000000..a5dbb6e --- /dev/null +++ b/tests/web/templates/test_jinja_response.py @@ -0,0 +1,74 @@ +from pathlib import Path + +from httpx import AsyncClient + +from selva.configuration.defaults import default_settings +from selva.configuration.settings import Settings +from selva.web.application import Selva + +path = str(Path(__file__).parent.absolute()) +settings = Settings( + default_settings + | { + "application": "tests.web.templates.application", + "templates": {"jinja": {"path": path}}, + } +) + + +async def test_render(): + app = Selva(settings) + await app._lifespan_startup() + + client = AsyncClient(app=app) + response = await client.get("http://localhost:8000/render") + + assert response.status_code == 200 + assert response.text == "Jinja" + assert response.headers["Content-Length"] == str(len("Jinja")) + assert "text/html" in response.headers["Content-Type"] + + +async def test_stream(): + app = Selva(settings) + await app._lifespan_startup() + + client = AsyncClient(app=app) + response = await client.get("http://localhost:8000/stream") + + assert response.status_code == 200 + assert response.text == "Jinja" + assert "Content-Length" not in response.headers + + +async def test_define_content_type(): + app = Selva(settings) + await app._lifespan_startup() + + client = AsyncClient(app=app) + response = await client.get("http://localhost:8000/define_content_type") + + assert response.status_code == 200 + assert "text/defined" in response.headers["Content-Type"] + + +async def test_override_content_type(): + app = Selva(settings) + await app._lifespan_startup() + + client = AsyncClient(app=app) + response = await client.get("http://localhost:8000/override_content_type") + + assert response.status_code == 200 + assert "text/overriden" in response.headers["Content-Type"] + + +async def test_content_type_from_response(): + app = Selva(settings) + await app._lifespan_startup() + + client = AsyncClient(app=app) + response = await client.get("http://localhost:8000/content_type_from_response") + + assert response.status_code == 200 + assert "text/from_response" in response.headers["Content-Type"]