Repository: vitalik/django-ninja
Pull request #149 (2021-06-10 07:00:16)

Add support for streaming responses (FileResponse, StreamingHttpResponse)
This PR adds support for streaming responses, specifically FileResponse and StreamingHttpResponse. Tests included. I didn't bother creating an issue first, please let me know if you need me to create an issue.

ErwinJunge: Thanks for your feedback. I've incorporated it in an updated push.

Additionally, I had to make some typing changes because the mypy test failed. I had to insert 2 `# type: ignore` statements though, because mypy was complaining about things that I didn't know how to tell it weren't actually a problem (missing `streaming_content` from `HttpResponse`, but it's inside a conditional).

Can you re-evaluate?

Patch:
diff --git a/ninja/operation.py b/ninja/operation.py
index cee614a..ffbd8cf 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -16,6 +16,7 @@ from typing import (
 import django
 import pydantic
 from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
+from django.http.response import HttpResponseBase
 
 from ninja.constants import NOT_SET
 from ninja.errors import ConfigError, ValidationError
@@ -84,7 +85,7 @@ class Operation:
         self.exclude_defaults = exclude_defaults
         self.exclude_none = exclude_none
 
-    def run(self, request: HttpRequest, **kw: Any) -> HttpResponse:
+    def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:
         error = self._run_checks(request)
         if error:
             return error
@@ -137,14 +138,16 @@ class Operation:
                 return None
         return self.api.create_response(request, {"detail": "Unauthorized"}, status=401)
 
-    def _result_to_response(self, request: HttpRequest, result: Any) -> HttpResponse:
+    def _result_to_response(
+        self, request: HttpRequest, result: Any
+    ) -> HttpResponseBase:
         """
         The protocol for results
          - if HttpResponse - returns as is
          - if tuple with 2 elements - means http_code + body
          - otherwise it's a body
         """
-        if isinstance(result, HttpResponse):
+        if isinstance(result, HttpResponseBase):
             return result
 
         status: int = 200
@@ -222,7 +225,7 @@ class AsyncOperation(Operation):
         super().__init__(*args, **kwargs)
         self.is_async = True
 
-    async def run(self, request: HttpRequest, **kw: Any) -> HttpResponse:  # type: ignore
+    async def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:  # type: ignore
         error = self._run_checks(request)
         if error:
             return error


Test patch:
diff --git a/ninja/testing/client.py b/ninja/testing/client.py
index d0637f0..ceacf92 100644
--- a/ninja/testing/client.py
+++ b/ninja/testing/client.py
@@ -3,7 +3,7 @@ from typing import Any, Callable, Dict, List, Tuple, Union
 from unittest.mock import Mock
 
 import django
-from django.http import QueryDict
+from django.http import QueryDict, StreamingHttpResponse
 from django.urls.resolvers import URLPattern
 
 from ninja import NinjaAPI, Router
@@ -139,11 +139,15 @@ class TestAsyncClient(NinjaClientBase):
 
 
 class NinjaResponse:
-    def __init__(self, http_response: HttpResponse):
+    def __init__(self, http_response: Union[HttpResponse, StreamingHttpResponse]):
         # TODO: what's the type here ?
         self._response = http_response
         self.status_code = http_response.status_code
-        self.content = http_response.content
+        self.streaming = http_response.streaming  # type: ignore
+        if self.streaming:
+            self.content = b"".join(http_response.streaming_content)  # type: ignore
+        else:
+            self.content = http_response.content
 
     def json(self) -> Any:
         return json_loads(self.content)
diff --git a/tests/test_app.py b/tests/test_app.py
index 54d1904..5a2e4a5 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,8 +1,9 @@
 import os
+from tempfile import NamedTemporaryFile
 import pytest
 from ninja import NinjaAPI
 from ninja.main import ConfigError
-from django.http import HttpResponse
+from django.http import FileResponse, HttpResponse
 from ninja.testing import TestClient
 
 
@@ -58,27 +59,40 @@ def html(request):
     return HttpResponse("html")
 
 
+@api.get("/file")
+def file_response(request):
+    tmp = NamedTemporaryFile(delete=False)
+    try:
+        with open(tmp.name, 'wb') as f:
+            f.write(b'this is a file')
+        return FileResponse(open(tmp.name, 'rb'))
+    finally:
+        os.remove(tmp.name)
+
+
 @pytest.mark.parametrize(
     # fmt: off
-    "method,path,expected_status,expected_data",
+    "method,path,expected_status,expected_data,expected_streaming",
     [
-        ("get",    "/",       200, "/"),
-        ("get",    "/get",    200, "this is GET"),
-        ("post",   "/post",   200, "this is POST"),
-        ("put",    "/put",    200, "this is PUT"),
-        ("patch",  "/patch",  200, "this is PATCH"),
-        ("delete", "/delete", 200, "this is DELETE"),
-        ("get",    "/multi",  200, "this is GET"),
-        ("post",   "/multi",  200, "this is POST"),
-        ("patch",  "/multi",  405, b"Method not allowed"),
-        ("get",    "/html",   200, b"html"),
+        ("get",    "/",       200, "/", False),
+        ("get",    "/get",    200, "this is GET", False),
+        ("post",   "/post",   200, "this is POST", False),
+        ("put",    "/put",    200, "this is PUT", False),
+        ("patch",  "/patch",  200, "this is PATCH", False),
+        ("delete", "/delete", 200, "this is DELETE", False),
+        ("get",    "/multi",  200, "this is GET", False),
+        ("post",   "/multi",  200, "this is POST", False),
+        ("patch",  "/multi",  405, b"Method not allowed", False),
+        ("get",    "/html",   200, b"html", False),
+        ("get",    "/file",   200, b"this is a file", True),
     ],
     # fmt: on
 )
-def test_method(method, path, expected_status, expected_data):
+def test_method(method, path, expected_status, expected_data, expected_streaming):
     func = getattr(client, method)
     response = func(path)
     assert response.status_code == expected_status
+    assert response.streaming == expected_streaming
     try:
         data = response.json()
     except Exception: