Repository: litestar-org/litestar

Issue:
Combining redirects and templated responses
## Context

I'm working on a project with non-API/non-RESTful routes where, depending on the result of backend processing, either one of these two types of responses should be returned:

1. A templated HTML response ([`Template`]); or
2. A redirect to another (external) URL ([`Redirect`])

I'm struggling on how to implement this and not sure whether I'm running into a documentation issue where it's just not clear, or an actual API design limitation of Starlite's response handling in general.

## Environment info

- `Linux l-057 6.1.18-200.fc37.x86_64 #1 SMP PREEMPT_DYNAMIC Sat Mar 11 16:09:14 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux`
- `Python 3.11.2`
- `starlite 1.51.7` ([full requirements.txt](https://gist.github.com/zoni/c3b0c2afc8f40c801f18ecc5b9f00d7b/8b1ec72b3895dddb0cd795c025ae90ac0e73cbbd#file-requirements-txt))

## Partial solutions

I've gotten quite close to a working solution that meets both goals, but it doesn't _quite_ get me where I want.
For clarity, I'll iterate over all possible approaches I've attempted so far.

### Template response

Point (1) can be achieved with simple [`Template`] response, which looks as follows:

https://gist.github.com/zoni/c3b0c2afc8f40c801f18ecc5b9f00d7b/000c36e412b093fc8ced1ab4e98d57d9f2aea119

### Redirect response

Point (2) can be achieved with simple [`Redirect`] response, which looks as follows:

https://gist.github.com/zoni/c3b0c2afc8f40c801f18ecc5b9f00d7b/c16f9c20f173eceea2be13181ff8fbb9b7649fc0

### Combining Template + Redirect

A naive attempt was to return a union of `Template` and `Redirect`: 

https://gist.github.com/zoni/c3b0c2afc8f40c801f18ecc5b9f00d7b/44e0e0e6e8ffe474b8ff75c1610720800fe5537d

This however fails with a `ValueError` on OpenAPI schema generation:

<details>
<summary>Traceback</summary>
<pre>
Traceback (most recent call last):
  File "/usr/lib64/python3.11/multiprocessing/process.py", line 314, in _bootstrap
    self.run()
  File "/usr/lib64/python3.11/multiprocessing/process.py", line 108, in run
    self._target(*self._args, **self._kwargs)
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/uvicorn/_subprocess.py", line 76, in subprocess_started
    target(sockets=sockets)
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/uvicorn/server.py", line 59, in run
    return asyncio.run(self.serve(sockets=sockets))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.11/asyncio/runners.py", line 190, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.11/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.11/asyncio/base_events.py", line 653, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/uvicorn/server.py", line 66, in serve
    config.load()
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/uvicorn/config.py", line 471, in load
    self.loaded_app = import_from_string(self.app)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/uvicorn/importer.py", line 21, in import_from_string
    module = importlib.import_module(module_str)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.11/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1206, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1178, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 940, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/home/work/starlite_issue/app.py", line 16, in <module>
    app = Starlite(
          ^^^^^^^^^
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/starlite/app.py", line 390, in __init__
    self.update_openapi_schema()
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/starlite/app.py", line 732, in update_openapi_schema
    self.openapi_schema = construct_open_api_with_schema_class(
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/work/.cache/pypoetry/virtualenvs/starlite-issue-5tyW83TP-py3.11/lib/python3.11/site-packages/pydantic_openapi_schema/utils/utils.py", line 53, in construct_open_api_with_schema_class
    schema_definitions = schema(schema_classes, ref_prefix=REF_PREFIX, by_alias=by_alias)["definitions"]
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "pydantic/schema.py", line 146, in pydantic.schema.schema
  File "pydantic/schema.py", line 581, in pydantic.schema.model_process_schema
  File "pydantic/schema.py", line 622, in pydantic.schema.model_type_schema
  File "pydantic/schema.py", line 255, in pydantic.schema.field_schema
  File "pydantic/schema.py", line 527, in pydantic.schema.field_type_schema
  File "pydantic/schema.py", line 850, in pydantic.schema.field_singleton_schema
  File "pydantic/schema.py", line 745, in pydantic.schema.field_singleton_sub_fields_schema
  File "pydantic/schema.py", line 527, in pydantic.schema.field_type_schema
  File "pydantic/schema.py", line 949, in pydantic.schema.field_singleton_schema
ValueError: Value not declarable with JSON Schema, field: name='background_BackgroundTask' type=BackgroundTask required=True
</pre>
</details>

I don't actually care about OpenAPI for these endpoints, so setting `include_in_schema=False` would be fine for me as well:

https://gist.github.com/zoni/c3b0c2afc8f40c801f18ecc5b9f00d7b/5710d90b2a6de48e3b251e8820f9d0d0abbc8552

This removes the exception, but doesn't get us the intended behavior however. Instead, it results in a JSON rendering of the [`Template`] and [`Redirect`] objects:

<details>
<summary>Result of the template response</summary>
<pre>
[17:00:13] work in ~
➜  http localhost:5000/world
HTTP/1.1 307 Temporary Redirect
content-length: 132
content-type: application/json
date: Wed, 22 Mar 2023 16:00:16 GMT
server: uvicorn

{
    "background": null,
    "context": {
        "name": "world"
    },
    "cookies": [],
    "encoding": "utf-8",
    "headers": {},
    "media_type": null,
    "name": "hello.html.j2"
}
</pre>
</details>

<details>
<summary>Result of the Redirect response</summary>
<pre>
[17:00:16] work in ~
➜  http localhost:5000/foo
HTTP/1.1 307 Temporary Redirect
content-length: 114
content-type: application/json
date: Wed, 22 Mar 2023 16:00:19 GMT
server: uvicorn

{
    "background": null,
    "cookies": [],
    "encoding": "utf-8",
    "headers": {},
    "media_type": null,
    "path": "http://example.com/foo"
}
</pre>
</details>

### Using only `Response`

The closest to achieving this goal that I've come up with is to use a `Response`, but then I don't have access to a Template engine to return a ***templated*** response anymore:

https://gist.github.com/zoni/c3b0c2afc8f40c801f18ecc5b9f00d7b/48d7b837f33a4b522657027d78bd006399b4f04e

Lack of templating aside, we can see this lets us mix a redirect with an arbitrary content response:

<details>
<summary>text/html content response</summary>
<pre>
[17:09:26] work in ~
➜  http localhost:5000/world
HTTP/1.1 200 OK
content-length: 11
content-type: text/html; charset=utf-8
date: Wed, 22 Mar 2023 16:09:30 GMT
server: uvicorn

Hello world
</pre>
</details>

<details>
<summary>redirect response (no content body)</summary>
<pre>
[17:09:30] work in ~
➜  http localhost:5000/foo
HTTP/1.1 307 Temporary Redirect
Transfer-Encoding: chunked
content-type: text/plain; charset=utf-8
date: Wed, 22 Mar 2023 16:09:38 GMT
location: http://example.com/foo
server: uvicorn
</pre>
</details>

(Also worth noting this requires setting `content="", media_type="text/plain"` on `Response()` otherwise it defaults to JSON with a `""` body as well, so it's a bit less ergonomic)

## Desired solution

Ideally, I'd like to be able to combine [`Template`] + [`Redirect`]. That is, a function signature like `def hello(name: str = "world") -> Template | Redirect:`

(Or for older Python versions, `def hello(name: str = "world") -> typing.Union[Template, Redirect]:`)

I'm also quite happy just returning `Response`, but then I would like a way to get access to the templating engine myself, so that it's possible to do something along the lines of (pseudo-code):

```python

@get("/{name:str}", include_in_schema=False)
def hello(name: str = "world", templater: TemplateEngine) -> Response:
        response_content = templater.get_template("hello.html.j2").render(context={"name": name})

        return Response(
            content=response_content,
            media_type="text/html",
        )
```

This _might_ already be possible and I just don't know how to get the TemplateEngine injected. If so, I'd love to be pointed at how this could be achieved, and I'll happily make a PR to at least document this better.

## Miscellaneous notes

While I'm talking only about Template and Redirect here as that is the use-case I'm trying to solve, I imagine the same issues likely exist around mixing in [`StreamingResponse`] and [`FileResponse`].

[`Template`]: https://docs.starliteproject.dev/latest/reference/datastructures/response_containers.html#starlite.datastructures.response_containers.Template
[`Redirect`]: https://docs.starliteproject.dev/latest/reference/datastructures/response_containers.html#starlite.datastructures.response_containers.Redirect
[`StreamingResponse`]: https://docs.starliteproject.dev/latest/reference/response.html#starlite.response.StreamingResponse
[`FileResponse`]: https://docs.starliteproject.dev/latest/reference/response.html#starlite.response.FileResponse



Discussion:
Thanks a lot for the very detailed write up!

So, this is actually possible, but not very ergonomic. 
Truth be told, templates haven't received a lot of attention but could certainly use some. Unfortunately I can't make any promises as to when that might happen.

Anyway, here's (one) possible way of doing this:

```python
from pathlib import Path

from starlite import (
    Redirect,
    Starlite,
    Template,
    TemplateConfig,
    Request,
    get,
    Response,
    ResponseContainer,
)
from starlite.contrib.jinja import JinjaTemplateEngine
from starlite.status_codes import HTTP_307_TEMPORARY_REDIRECT


@get("/{name:str}", include_in_schema=False)
def hello(name: str, request: Request) -> ResponseContainer | Response:
    if name == "foo":
        return Redirect(path=f"http://example.com/{name}").to_response(
            headers={},
            status_code=HTTP_307_TEMPORARY_REDIRECT,
            media_type="text/plain",
            request=request,
            app=request.app,
        )

    return Template(name="hello.html.j2", context={"name": name})


app = Starlite(
    route_handlers=[hello],
    template_config=TemplateConfig(
        directory=Path("templates"),
        engine=JinjaTemplateEngine,
    ),
)
```

or alternatively: 

```python
@get("/{name:str}", include_in_schema=False)
def hello(name: str) -> ResponseContainer | Response:
    if name == "foo":
        return Response(
            content=b"",
            headers={"location": f"http://example.com/{name}"},
            status_code=HTTP_307_TEMPORARY_REDIRECT,
        )

    return Template(name="hello.html.j2", context={"name": name})
```

Both aren't that ergonomic, and there should definitely be a better way.

The actual issue though lies within the `Redirect` response container. Because it does not accept a `status_code`, you cannot simply return it. If this were the case, you could do:

```python
@get("/{name:str}", include_in_schema=False)
def hello(name: str) -> ResponseContainer:
    if name == "foo":
        return Redirect(path=f"http://example.com/{name}", status_code=HTTP_307_TEMPORARY_REDIRECT)

    return Template(name="hello.html.j2", context={"name": name})
```

which is quite a lot nicer.

I'll open an issue to support this, although it will most likely end up in 2.0.

<hr> 

From a documentation perspective, we should definitely have a section about returning optional redirect like that.

Resolving patch:
diff --git a/starlite/handlers/http_handlers/base.py b/starlite/handlers/http_handlers/base.py
--- a/starlite/handlers/http_handlers/base.py
+++ b/starlite/handlers/http_handlers/base.py
@@ -8,14 +8,12 @@
 
 from starlite._layers.utils import narrow_response_cookies, narrow_response_headers
 from starlite._signature.utils import get_signature_model
-from starlite.constants import REDIRECT_STATUS_CODES
 from starlite.datastructures.cookie import Cookie
 from starlite.datastructures.response_header import ResponseHeader
 from starlite.enums import HttpMethod, MediaType
 from starlite.exceptions import (
     HTTPException,
     ImproperlyConfiguredException,
-    ValidationException,
 )
 from starlite.handlers.base import BaseRouteHandler
 from starlite.handlers.http_handlers._utils import (
@@ -500,15 +498,6 @@ def _validate_handler_function(self) -> None:
                 "If the function should return a value, change the route handler status code to an appropriate value.",
             )
 
-        if (
-            is_class_and_subclass(self.signature.return_annotation, Redirect)
-            and self.status_code not in REDIRECT_STATUS_CODES
-        ):
-            raise ValidationException(
-                f"Redirect responses should have one of "
-                f"the following status codes: {', '.join([str(s) for s in REDIRECT_STATUS_CODES])}"
-            )
-
         if (
             is_class_and_subclass(self.signature.return_annotation, File)
             or is_class_and_subclass(self.signature.return_annotation, FileResponse)
diff --git a/starlite/response_containers.py b/starlite/response_containers.py
--- a/starlite/response_containers.py
+++ b/starlite/response_containers.py
@@ -215,6 +215,8 @@ class Redirect(ResponseContainer[RedirectResponse]):
     """If defined, overrides the media type configured in the route decorator."""
     encoding: str = field(default="utf-8")
     """The encoding to be used for the response headers."""
+    status_code: Literal[301, 302, 303, 307, 308] | None = None
+    """Redirect status code"""
 
     def to_response(  # type: ignore[override]
         self,
@@ -242,7 +244,7 @@ def to_response(  # type: ignore[override]
             background=self.background,
             encoding=self.encoding,
             headers=headers,
-            status_code=status_code,
+            status_code=self.status_code or status_code,
             url=self.path,
         )
 


Test patch:
diff --git a/tests/handlers/http/test_validations.py b/tests/handlers/http/test_validations.py
--- a/tests/handlers/http/test_validations.py
+++ b/tests/handlers/http/test_validations.py
@@ -34,19 +34,13 @@ def test_route_handler_validation_http_method() -> None:
         route(http_method=[HttpMethod.GET, "poft"], status_code=HTTP_200_OK)  # type: ignore
 
 
-async def test_function_validation(anyio_backend: str) -> None:
+async def test_function_validation() -> None:
     with pytest.raises(ImproperlyConfiguredException):
 
         @get(path="/")
         def method_with_no_annotation():  # type: ignore
             pass
 
-    with pytest.raises(ValidationException):
-
-        @get(path="/", status_code=HTTP_200_OK)
-        def redirect_method_without_proper_status() -> Redirect:
-            return Redirect(path="/redirected")
-
     with pytest.raises(ImproperlyConfiguredException):
 
         @delete(path="/")
diff --git a/tests/test_response_containers.py b/tests/test_response_containers.py
--- a/tests/test_response_containers.py
+++ b/tests/test_response_containers.py
@@ -2,7 +2,7 @@
 from inspect import iscoroutine
 from os import stat
 from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Optional
 
 import pytest
 from fsspec.implementations.local import LocalFileSystem
@@ -11,7 +11,7 @@
 from starlite.datastructures import ETag
 from starlite.exceptions import ImproperlyConfiguredException
 from starlite.file_system import BaseLocalFileSystem
-from starlite.response_containers import File
+from starlite.response_containers import File, Redirect
 from starlite.status_codes import HTTP_200_OK
 from starlite.testing import RequestFactory, create_test_client
 
@@ -155,3 +155,34 @@ def open(self) -> None:
         path=path,
         file_system=ImplementedFS(),
     )
+
+
+@pytest.mark.parametrize(
+    "status_code,expected_status_code",
+    [
+        (301, 301),
+        (302, 302),
+        (303, 303),
+        (307, 307),
+        (308, 308),
+    ],
+)
+def test_redirect_dynamic_status_code(status_code: Optional[int], expected_status_code: int) -> None:
+    @get("/")
+    def handler() -> Redirect:
+        return Redirect(path="/something-else", status_code=status_code)  # type: ignore[arg-type]
+
+    with create_test_client([handler]) as client:
+        res = client.get("/", follow_redirects=False)
+        assert res.status_code == expected_status_code
+
+
+@pytest.mark.parametrize("handler_status_code", [301, 307, None])
+def test_redirect(handler_status_code: Optional[int]) -> None:
+    @get("/", status_code=handler_status_code)
+    def handler() -> Redirect:
+        return Redirect(path="/something-else", status_code=301)
+
+    with create_test_client([handler]) as client:
+        res = client.get("/", follow_redirects=False)
+        assert res.status_code == 301