Repository: litestar-org/litestar

Issue:
File response should accept byte stream and `media_type` as an alternative to file path.
**What's the feature you'd like to ask for.**

As of v1.18.1: Starlite's [File](https://starlite-api.github.io/starlite/usage/5-responses/7-file-responses/) response only supports returning existing files.

``` python
return File(
        path=Path(Path(__file__).resolve().parent, "report").with_suffix(".pdf"),
        filename="repost.pdf",
    )

```

However, it should be able to support returning files directly from byte streams such as BytesIO. This would enable apps that can't use filesystem (eg. Google App Engine) to return files without much workaround.

In addition to that, File response should _optionally_ accept `media_type` as it is quite useful to set it dynamically depending on the file content.

Therefore, we should be able to return file by passing data and media type to File class like:
``` python
...
data = io.BytesIO()
... do something data..
return File(
        content=data,
        filename=filename,
        media_type='application/vnd.ms-excel',
        headers={f'Content-Disposition': f'attachment; filename={filename}'},
    )
```

❤️  Thank you very much :)



Discussion:
Would a streaming response be what you are looking for here?

```python
"""Minimal Starlite application."""
import io

from starlite import Starlite, get
from starlite.datastructures import Stream
from starlite.enums import MediaType


@get("/", media_type=MediaType.TEXT)
def hello_world() -> Stream:
    byte_stream = io.BytesIO(b"some byte string\n")
    """Hi."""
    return Stream(iterator=byte_stream)


app = Starlite(route_handlers=[hello_world])
```

```
$ curl localhost:8000/
some byte string
```
Nope, I solely meant using stream as source for file download. 
Sure but when it boils down to it, `FileResponse` is streaming chunks of bytes just like `StreamingResponse` does:

From [FileResponse](https://github.com/encode/starlette/blob/bc61505faef15b673bf4bf65db4927daa354d6b8/starlette/responses.py#L349-L360):
```python
            async with await anyio.open_file(self.path, mode="rb") as file:
                more_body = True
                while more_body:
                    chunk = await file.read(self.chunk_size)
                    more_body = len(chunk) == self.chunk_size
                    await send(
                        {
                            "type": "http.response.body",
                            "body": chunk,
                            "more_body": more_body,
                        }
                    )
```

And from [StreamingResponse](https://github.com/encode/starlette/blob/bc61505faef15b673bf4bf65db4927daa354d6b8/starlette/responses.py#L258-L263):

```python
        async for chunk in self.body_iterator:
            if not isinstance(chunk, bytes):
                chunk = chunk.encode(self.charset)
            await send({"type": "http.response.body", "body": chunk, "more_body": True})

        await send({"type": "http.response.body", "body": b"", "more_body": False})
```
Say we added a `content` attribute to `File` we'd have to produce a `StreamResponse` from it anyway, unless we wrote the stream to a temporary file before passing it to `FileResponse`, which I dont think is the way to go.

Also setting headers and media type on the responses is possible if you want to drop down a level and call the `to_response()` method:

```python
"""Minimal Starlite application."""
import io

from starlette.responses import StreamingResponse
from starlite import Starlite, get, Request
from starlite.datastructures import Stream
from starlite.enums import MediaType


@get("/", media_type=MediaType.TEXT)
def hello_world(request: Request) -> StreamingResponse:
    byte_stream = io.BytesIO(b"some byte string\n\n")
    """Hi."""
    return Stream(iterator=byte_stream).to_response(
        headers={f"Content-Disposition": f"attachment; filename=my-file"},
        media_type="application/vnd.ms-excel",
        status_code=200,
        app=request.app,
    )


app = Starlite(route_handlers=[hello_world])
```

Issue with that though is that we lose the openapi docs when we return the starlette response directly.
![image](https://user-images.githubusercontent.com/20659309/190387228-4e4ef88c-a7ee-46dc-8f12-21645c5abeb3.png)

Thanks a lot for the tip! I managed to get it working as well.

(https://github.com/denizkenan/starlite-hello-world)
`curl localhost:8000/greetings`

I fully agree with you. It is just better dx to handle this purely within starlite using File response. I don't think it is super edge case for us to use lower level APIs.



So if we made `media_type` configurable on `ResponseContainer` so you could do this:

```python
"""Minimal Starlite application."""
import io

from starlite import Starlite, get
from starlite.datastructures import Stream
from starlite.enums import MediaType


@get("/", media_type=MediaType.TEXT)
def starlite() -> Stream:
    byte_stream = io.BytesIO(b"some byte string\n\n")
    """Hi."""
    return Stream(
        iterator=byte_stream,
        headers={f"Content-Disposition": f"attachment; filename=my-file"},
        media_type="application/vnd.ms-excel",
    )


app = Starlite(route_handlers=[starlite])
```
Would that be the solution you are looking for?

Resolving patch:
diff --git a/starlite/datastructures.py b/starlite/datastructures.py
--- a/starlite/datastructures.py
+++ b/starlite/datastructures.py
@@ -36,6 +36,7 @@
 from starlite_multipart.datastructures import UploadFile as MultipartUploadFile
 from typing_extensions import Literal, ParamSpec
 
+from starlite.enums import MediaType
 from starlite.openapi.enums import OpenAPIType
 
 P = ParamSpec("P")
@@ -44,7 +45,6 @@
     from pydantic.fields import ModelField
 
     from starlite.app import Starlite
-    from starlite.enums import MediaType
     from starlite.response import TemplateResponse
 
 
@@ -156,6 +156,8 @@ class Config(BaseConfig):
     """A string/string dictionary of response headers. Header keys are insensitive. Defaults to None."""
     cookies: List[Cookie] = []
     """A list of Cookie instances to be set under the response 'Set-Cookie' header. Defaults to None."""
+    media_type: Optional[Union[MediaType, str]] = None
+    """If defined, overrides the media type configured in the route decorator"""
 
     @abstractmethod
     def to_response(
diff --git a/starlite/handlers/http.py b/starlite/handlers/http.py
--- a/starlite/handlers/http.py
+++ b/starlite/handlers/http.py
@@ -138,7 +138,9 @@ def _create_response_container_handler(
     async def handler(data: ResponseContainer, app: "Starlite", **kwargs: Any) -> StarletteResponse:
         normalized_headers = {**_normalize_headers(headers), **data.headers}
         normalized_cookies = _normalize_cookies(data.cookies, cookies)
-        response = data.to_response(app=app, headers=normalized_headers, status_code=status_code, media_type=media_type)
+        response = data.to_response(
+            app=app, headers=normalized_headers, status_code=status_code, media_type=data.media_type or media_type
+        )
         for cookie in normalized_cookies:
             response.set_cookie(**cookie)
         return await after_request(response) if after_request else response  # type: ignore
@@ -498,7 +500,6 @@ def resolve_response_handler(
             response_class = self.resolve_response_class()
             headers = self.resolve_response_headers()
             cookies = self.resolve_response_cookies()
-
             if is_class_and_subclass(self.signature.return_annotation, ResponseContainer):  # type: ignore[misc]
                 handler = _create_response_container_handler(
                     after_request=after_request,


Test patch:
diff --git a/tests/response/test_serialization.py b/tests/response/test_serialization.py
--- a/tests/response/test_serialization.py
+++ b/tests/response/test_serialization.py
@@ -15,7 +15,7 @@
 pure_path = PurePath("/path/to/file")
 
 
-class TestEnum(enum.Enum):
+class _TestEnum(enum.Enum):
     A = "alpha"
     B = "beta"
 
@@ -30,13 +30,15 @@ class TestEnum(enum.Enum):
         [PydanticDataClassPerson(**person.dict()), PydanticDataClassPerson, MediaType.JSON],
         ["abcdefg", str, MediaType.TEXT],
         ["<div/>", str, MediaType.HTML],
-        [{"enum": TestEnum.A}, Dict[str, TestEnum], MediaType.JSON],
+        [{"enum": _TestEnum.A}, Dict[str, _TestEnum], MediaType.JSON],
         [{"secret": secret}, Dict[str, SecretStr], MediaType.JSON],
         [{"pure_path": pure_path}, Dict[str, PurePath], MediaType.JSON],
     ],
 )
 def test_response_serialization(content: Any, response_type: Any, media_type: MediaType) -> None:
-    response = Response[response_type](content, media_type=media_type, status_code=HTTP_200_OK)  # type: ignore[valid-type]
+    response = Response[response_type](  # type: ignore[valid-type]
+        content, media_type=media_type, status_code=HTTP_200_OK
+    )
     if media_type == MediaType.JSON:
         value = loads(response.body)
         if isinstance(value, dict) and "enum" in value: