Repository: vitalik/django-ninja

Issue:
Provide a way to specify response headers
Seems like currently [Operation._result_to_response](https://github.com/vitalik/django-ninja/blob/5a19d230cb451f1f3ab0e167460bc05f49a6554e/ninja/operation.py#L106-L135) do not expect response headers from view function result (if not already part of `HttpResponse` instance).

This makes passing response headers tedious task.

Maybe `django-ninja` should allow view handler to setup response headers,

1. Via [temporal Response instance](https://fastapi.tiangolo.com/advanced/response-headers/) as done in FastAPI 
2. Or via [last tuple item](https://flask.palletsprojects.com/en/1.1.x/quickstart/#about-responses) as done in Flask

What do you think?

Comments:

Patch:
diff --git a/docs/docs/tutorial/temporal_response.md b/docs/docs/tutorial/temporal_response.md
new file mode 100644
index 0000000..e767441
--- /dev/null
+++ b/docs/docs/tutorial/temporal_response.md
@@ -0,0 +1,38 @@
+# Altering the Response
+
+Sometimes you'll want to change the response just before it gets served, for example, to add a header or alter a cookie.
+
+To do this, simply declare a function parameter with a type of `HttpResponse`:
+
+```Python
+from django.http import HttpRequest, HttpResponse
+
+@api.get("/cookie/")
+def feed_cookiemonster(request: HttpRequest, response: HttpResponse):
+    # Set a cookie.
+    response.set_cookie("cookie", "delicious")
+    # Set a header.
+    response["X-Cookiemonster"] = "blue"
+    return {"cookiemonster_happy": True}
+```
+
+
+## Temporal response object
+
+This response object is used for the base of all responses built by Django Ninja, including error responses. This object is *not* used if a Django `HttpResponse` object is returned directly by an operation.
+
+Obviously this response object won't contain the content yet, but it does have the `content_type` set (but you probably don't want to be changing it).
+
+The `status_code` will get overridden depending on the return value (200 by default, or the status code if a two-part tuple is returned).
+
+
+## Changing the base response object
+
+You can alter this temporal response object by overriding the `NinjaAPI.create_temporal_response` method.
+
+```Python
+    def create_temporal_response(self, request: HttpRequest) -> HttpResponse:
+        response = super().create_temporal_response(request)
+        # Do your magic here...
+        return response
+```
\ No newline at end of file
diff --git a/ninja/main.py b/ninja/main.py
index fbf540e..27ba4d6 100644
--- a/ninja/main.py
+++ b/ninja/main.py
@@ -335,13 +335,34 @@ class NinjaAPI:
         return reverse(name)
 
     def create_response(
-        self, request: HttpRequest, data: Any, *, status: int = 200
+        self,
+        request: HttpRequest,
+        data: Any,
+        *,
+        status: int = None,
+        temporal_response: HttpResponse = None,
     ) -> HttpResponse:
+        if temporal_response:
+            status = temporal_response.status_code
+        assert status
+
         content = self.renderer.render(request, data, response_status=status)
-        content_type = "{}; charset={}".format(
-            self.renderer.media_type, self.renderer.charset
-        )
-        return HttpResponse(content, status=status, content_type=content_type)
+
+        if temporal_response:
+            response = temporal_response
+            response.content = content
+        else:
+            response = HttpResponse(
+                content, status=status, content_type=self.get_content_type()
+            )
+
+        return response
+
+    def create_temporal_response(self, request: HttpRequest) -> HttpResponse:
+        return HttpResponse("", content_type=self.get_content_type())
+
+    def get_content_type(self) -> str:
+        return "{}; charset={}".format(self.renderer.media_type, self.renderer.charset)
 
     def get_openapi_schema(self, path_prefix: Optional[str] = None) -> OpenAPISchema:
         if path_prefix is None:
diff --git a/ninja/operation.py b/ninja/operation.py
index 2fa0935..dcdb955 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -94,9 +94,10 @@ class Operation:
         if error:
             return error
         try:
-            values = self._get_values(request, kw)
+            temporal_response = self.api.create_temporal_response(request)
+            values = self._get_values(request, kw, temporal_response)
             result = self.view_func(request, **values)
-            return self._result_to_response(request, result)
+            return self._result_to_response(request, result, temporal_response)
         except Exception as e:
             if isinstance(e, TypeError) and "required positional argument" in str(e):
                 msg = "Did you fail to use functools.wraps() in a decorator?"
@@ -151,7 +152,7 @@ class Operation:
         return self.api.create_response(request, {"detail": "Unauthorized"}, status=401)
 
     def _result_to_response(
-        self, request: HttpRequest, result: Any
+        self, request: HttpRequest, result: Any, temporal_response: HttpResponse
     ) -> HttpResponseBase:
         """
         The protocol for results
@@ -179,13 +180,16 @@ class Operation:
                 f"Schema for status {status} is not set in response {self.response_models.keys()}"
             )
 
+        temporal_response.status_code = status
+
         if response_model is NOT_SET:
-            return self.api.create_response(request, result, status=status)
+            return self.api.create_response(
+                request, result, temporal_response=temporal_response
+            )
 
         if response_model is None:
-            return HttpResponse(status=status)
-            # TODO: ^ maybe self.api.create_empty_response ?
-            # return self.api.create_response(request, result, status=status)
+            # Empty response.
+            return temporal_response
 
         resp_object = ResponseObject(result)
         # ^ we need object because getter_dict seems work only with from_orm
@@ -195,9 +199,13 @@ class Operation:
             exclude_defaults=self.exclude_defaults,
             exclude_none=self.exclude_none,
         )["response"]
-        return self.api.create_response(request, result, status=status)
+        return self.api.create_response(
+            request, result, temporal_response=temporal_response
+        )
 
-    def _get_values(self, request: HttpRequest, path_params: Any) -> DictStrAny:
+    def _get_values(
+        self, request: HttpRequest, path_params: Any, temporal_response: HttpResponse
+    ) -> DictStrAny:
         values, errors = {}, []
         for model in self.models:
             try:
@@ -213,6 +221,8 @@ class Operation:
                 errors.extend(items)
         if errors:
             raise ValidationError(errors)
+        if self.signature.response_arg:
+            values[self.signature.response_arg] = temporal_response
         return values
 
     def _create_response_model_multiple(
@@ -244,9 +254,10 @@ class AsyncOperation(Operation):
         if error:
             return error
         try:
-            values = self._get_values(request, kw)
+            temporal_response = self.api.create_temporal_response(request)
+            values = self._get_values(request, kw, temporal_response)
             result = await self.view_func(request, **values)
-            return self._result_to_response(request, result)
+            return self._result_to_response(request, result, temporal_response)
         except Exception as e:
             return self.api.on_exception(request, e)
 
diff --git a/ninja/signature/details.py b/ninja/signature/details.py
index 4aea69d..a740684 100644
--- a/ninja/signature/details.py
+++ b/ninja/signature/details.py
@@ -1,9 +1,10 @@
 import inspect
 import warnings
 from collections import defaultdict, namedtuple
-from typing import Any, Callable, Dict, Generator, List, Tuple, Union
+from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
 
 import pydantic
+from django.http import HttpResponse
 
 from ninja import UploadedFile, params
 from ninja.compatibility.util import get_args, get_origin as get_collection_origin
@@ -28,6 +29,7 @@ class ViewSignature:
     FLATTEN_PATH_SEP = (
         "\x1e"  # ASCII Record Separator.  IE: not generally used in query names
     )
+    response_arg: Optional[str] = None
 
     def __init__(self, path: str, view_func: Callable) -> None:
         self.view_func = view_func
@@ -54,6 +56,10 @@ class ViewSignature:
                 # Skipping *args
                 continue
 
+            if arg.annotation is HttpResponse:
+                self.response_arg = name
+                continue
+
             func_param = self._get_param_type(name, arg)
             self.params.append(func_param)
 


Test patch:
diff --git a/ninja/testing/client.py b/ninja/testing/client.py
index a50db15..b471bfb 100644
--- a/ninja/testing/client.py
+++ b/ninja/testing/client.py
@@ -1,5 +1,6 @@
+from http import cookies
 from json import dumps as json_dumps, loads as json_loads
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
 from unittest.mock import Mock
 from urllib.parse import urljoin
 
@@ -169,5 +170,10 @@ class NinjaResponse:
     def __getitem__(self, key: str) -> Any:
         return self._response[key]
 
+    @property
+    def cookies(self) -> cookies.SimpleCookie:
+        return cast(cookies.SimpleCookie, self._response.cookies)
+
     def __getattr__(self, attr: str) -> Any:
         return getattr(self._response, attr)
+ 
diff --git a/tests/test_response.py b/tests/test_response.py
index 0ec9ffe..7d2ae3d 100644
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -1,6 +1,7 @@
 from typing import List, Union
 
 import pytest
+from django.http import HttpResponse
 from pydantic import BaseModel, ValidationError
 
 from ninja import Router
@@ -65,6 +66,25 @@ def check_union(request, q: int):
     return "invalid"
 
 
+@router.get("/check_set_header")
+def check_set_header(request, response: HttpResponse):
+    response["Cache-Control"] = "no-cache"
+    return 1
+
+
+@router.get("/check_set_cookie")
+def check_set_cookie(request, set: bool, response: HttpResponse):
+    if set:
+        response.set_cookie("test", "me")
+    return 1
+
+
+@router.get("/check_del_cookie")
+def check_del_cookie(request, response: HttpResponse):
+    response.delete_cookie("test")
+    return 1
+
+
 client = TestClient(router)
 
 
@@ -95,3 +115,28 @@ def test_validates():
 
     with pytest.raises(ValidationError):
         client.get("/check_union?q=2")
+
+
+def test_set_header():
+    response = client.get("/check_set_header")
+    assert response.status_code == 200
+    assert response.content == b"1"
+    assert response["Cache-Control"] == "no-cache"
+
+
+def test_set_cookie():
+    response = client.get("/check_set_cookie?set=0")
+    assert "test" not in response.cookies
+
+    response = client.get("/check_set_cookie?set=1")
+    cookie = response.cookies.get("test")
+    assert cookie
+    assert cookie.value == "me"
+
+
+def test_del_cookie():
+    response = client.get("/check_del_cookie")
+    cookie = response.cookies.get("test")
+    assert cookie
+    assert cookie["expires"] == "Thu, 01 Jan 1970 00:00:00 GMT"
+    assert cookie["max-age"] == 0