Repository: vitalik/django-ninja

Issue:
Decorators
```
@api.get("/add")
@cache_page(60 * 15)
def add(request, a: int, b: int):
    return {"result": a + b}
```
so currently this will not work due to inspection procedure...

I guess there must be some `@decorate` helper to allow to add custom decorators that will not conflict with arguments/annotations resolution

UPD:

looks like when `functools.wraps` is used it is possible to get to the original function declaration via `__wrapped__` attribute




Discussion:

Patch:
diff --git a/ninja/operation.py b/ninja/operation.py
index 1956a61..f9718af 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -94,6 +94,10 @@ class Operation:
             result = self.view_func(request, **values)
             return self._result_to_response(request, result)
         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?"
+                msg = f"{e.args[0]}: {msg}" if e.args else msg
+                e.args = (msg,) + e.args[1:]
             return self.api.on_exception(request, e)
 
     def set_api_instance(self, api: "NinjaAPI", router: "Router") -> None:


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index 5a2e4a5..307f0c5 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -63,11 +63,14 @@ def html(request):
 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'))
+        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)
+        try:
+            os.remove(tmp.name)
+        except PermissionError:
+            pass
 
 
 @pytest.mark.parametrize(
@@ -109,4 +112,3 @@ def test_validates():
             urls = api2.urls
     finally:
         os.environ["NINJA_SKIP_REGISTRY"] = "yes"
-
diff --git a/tests/test_wraps.py b/tests/test_wraps.py
new file mode 100644
index 0000000..b5800ed
--- /dev/null
+++ b/tests/test_wraps.py
@@ -0,0 +1,103 @@
+from functools import wraps
+import pytest
+from ninja import Router
+from ninja.testing import TestClient
+
+
+router = Router()
+client = TestClient(router)
+
+
+def a_good_test_wrapper(f):
+    """Validate that decorators using functools.wraps(), work as expected"""
+
+    @wraps(f)
+    def wrapper(*args, **kwargs):
+        return f(*args, **kwargs)
+
+    return wrapper
+
+
+def a_bad_test_wrapper(f):
+    """Validate that decorators failing to using functools.wraps(), fail"""
+
+    def wrapper(*args, **kwargs):
+        return f(*args, **kwargs)
+
+    return wrapper
+
+
+@router.get("/text")
+@a_good_test_wrapper
+def get_text(
+    request,
+):
+    return "Hello World"
+
+
+@router.get("/path/{item_id}")
+@a_good_test_wrapper
+def get_id(request, item_id):
+    return item_id
+
+
+@router.get("/query")
+@a_good_test_wrapper
+def get_query_type(request, query: int):
+    return f"foo bar {query}"
+
+
+@router.get("/path-query/{item_id}")
+@a_good_test_wrapper
+def get_id(request, item_id, query: int):
+    return f"foo bar {item_id} {query}"
+
+
+@router.get("/text-bad")
+@a_bad_test_wrapper
+def get_text(
+    request,
+):
+    return "Hello World"
+
+
+@router.get("/path-bad/{item_id}")
+@a_bad_test_wrapper
+def get_id(request, item_id):
+    return item_id
+
+
+@router.get("/query-bad")
+@a_bad_test_wrapper
+def get_query_type(request, query: int):
+    return f"foo bar {query}"
+
+
+@router.get("/path-query-bad/{item_id}")
+@a_bad_test_wrapper
+def get_id_bad(request, item_id, query: int):
+    return f"foo bar {item_id} {query}"
+
+
+@pytest.mark.parametrize(
+    "path,expected_status,expected_response",
+    [
+        ("/text", 200, "Hello World"),
+        ("/path/id", 200, "id"),
+        ("/query?query=1", 200, "foo bar 1"),
+        ("/path-query/id?query=2", 200, "foo bar id 2"),
+        ("/text-bad", 200, "Hello World"),  # no params so passes
+        ("/path-bad/id", None, TypeError),
+        ("/query-bad?query=1", None, TypeError),
+        ("/path-query-bad/id?query=2", None, TypeError),
+    ],
+)
+def test_get_path(path, expected_status, expected_response):
+    if isinstance(expected_response, str):
+        response = client.get(path)
+        assert response.status_code == expected_status
+        assert response.json() == expected_response
+    else:
+        match = r"Did you fail to use functools.wraps\(\) in a decorator\?"
+        with pytest.raises(expected_response, match=match):
+            client.get(path)