Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement] : Add middleware for PATCH | PUT file upload #719

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test_full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
django-version: ['<3.0', '<3.1', '<3.2', '<3.3', '<4.1', '<4.2']
django-version: ['<3.2', '<3.3', '<4.1', '<4.2']

steps:
- uses: actions/checkout@v3
Expand Down
35 changes: 23 additions & 12 deletions docs/docs/guides/input/file-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,20 @@ def upload(request, file: UploadedFile = File(...)):
return {'name': file.name, 'len': len(data)}
```


`UploadedFile` is an alias to [Django's UploadFile](https://docs.djangoproject.com/en/stable/ref/files/uploads/#django.core.files.uploadedfile.UploadedFile) and has all the methods and attributes to access the uploaded file:

- read()
- multiple_chunks(chunk_size=None)
- chunks(chunk_size=None)
- name
- size
- content_type
- etc.
- read()
- multiple_chunks(chunk_size=None)
- chunks(chunk_size=None)
- name
- size
- content_type
- etc.

## Uploading array of files

To **upload several files** at the same time, just declare a `List` of `UploadFile`:


```Python hl_lines="1 6"
from typing import List
from ninja import NinjaAPI, File
Expand Down Expand Up @@ -75,6 +73,19 @@ def create_user(request, details: UserDetails, file: UploadedFile = File(...)):
```

this will expect from client side to send data as multipart/form-data with 2 fields:

- details: Json as string
- file: file

- details: Json as string
- file: file

# Uploading files via PUT / PATCH method

Note: Django doesn't allow uploading files via [`PATCH` | `PUT` request](https://docs.djangoproject.com/en/4.1/topics/http/file-uploads/#file-uploads). In order to facilitate this feature `django-ninja` ships a middleware to load the files in `PATCH` | `PUT` request.

You can add this middleware to django's settings.py:

```python
MIDDLEWARES = [
"ninja.middlewares.process_put_patch",
...
]
```
39 changes: 39 additions & 0 deletions ninja/middlewares.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from collections.abc import Callable
from typing import Any

from asgiref.sync import iscoroutinefunction, sync_to_async
from django.http import HttpRequest
from django.utils.decorators import sync_and_async_middleware


@sync_and_async_middleware
def process_put_patch(get_response: Callable) -> Callable:
async def async_middleware(request: HttpRequest) -> Any: # pragma: no cover
if (
request.method in ("PUT", "PATCH")
and request.content_type != "application/json"
):
initial_method = request.method
request.method = "POST"
request.META["REQUEST_METHOD"] = "POST"
await sync_to_async(request._load_post_and_files)()
request.META["REQUEST_METHOD"] = initial_method
request.method = initial_method

return await get_response(request)

def sync_middleware(request: HttpRequest) -> Any: # pragma: no cover
if (
request.method in ("PUT", "PATCH")
and request.content_type != "application/json"
):
initial_method = request.method
request.method = "POST"
request.META["REQUEST_METHOD"] = "POST"
request._load_post_and_files()
request.META["REQUEST_METHOD"] = initial_method
request.method = initial_method

return get_response(request)

return async_middleware if iscoroutinefunction(get_response) else sync_middleware
3 changes: 0 additions & 3 deletions ninja/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
cast,
)

import django
import pydantic
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
from django.http.response import HttpResponseBase
Expand Down Expand Up @@ -250,8 +249,6 @@ def _create_response_model(self, response_param: Any) -> Optional[Type[Schema]]:

class AsyncOperation(Operation):
def __init__(self, *args: Any, **kwargs: Any) -> None:
if django.VERSION < (3, 1): # pragma: no cover
raise Exception("Async operations are supported only with Django 3.1+")
super().__init__(*args, **kwargs)
self.is_async = True

Expand Down
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
"Framework :: Django",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.0",
"Framework :: Django :: 3.1",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",
Expand All @@ -42,8 +40,9 @@ classifiers = [
]

requires = [
"Django >=2.2",
"pydantic >=1.6,<2.0.0"
"asgiref < 4.0.0",
"Django >= 3.2",
"pydantic >= 1.6,<2.0.0",
]
description-file = "README.md"
requires-python = ">=3.7"
Expand Down
6 changes: 2 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
# To be removed once GitHub catches up.

setup(
name='django-ninja',
install_requires=[
'Django>=2.0.13'
],
name="django-ninja",
install_requires=["Django>=3.2"],
)
2 changes: 1 addition & 1 deletion tests/env-matrix/install_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ eval "$(pyenv virtualenv-init -)"

pyenv virtualenv $PYVER $ENVNAME
pyenv shell $ENVNAME
pip install django==$DJANGO pytest pytest-django pytest-asyncio pytest-cov pydantic==1.6
pip install django==$DJANGO pytest pytest-django pytest-asyncio pytest-cov pydantic==1.6 asgiref>=3.6.0
2 changes: 0 additions & 2 deletions tests/test_async.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import asyncio

import django
import pytest

from ninja import NinjaAPI
from ninja.security import APIKeyQuery
from ninja.testing import TestAsyncClient


@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
@pytest.mark.asyncio
async def test_asyncio_operations():
api = NinjaAPI()
Expand Down
2 changes: 0 additions & 2 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import django
import pytest
from django.http import Http404

Expand Down Expand Up @@ -73,7 +72,6 @@ def test_exceptions(route, status_code, json):
assert response.json() == json


@pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher")
@pytest.mark.asyncio
async def test_asyncio_exceptions():
api = NinjaAPI()
Expand Down
89 changes: 89 additions & 0 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from unittest import mock

import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import RequestFactory, TestCase, override_settings

from ninja import File, NinjaAPI, UploadedFile
from ninja.middlewares import process_put_patch
from ninja.testing import TestAsyncClient, TestClient


@override_settings(MIDDLEWARES=("ninja.middlewares.process_put_patch"))
def test_sync_patch_put_middleware():
api = NinjaAPI()

@api.patch("/sync/random-file")
def sync_patch_random_file(request, file: UploadedFile = File(...)):
return {"name": file.name, "data": file.read().decode()}

@api.put("/sync/random-file")
def sync_put_random_file(request, file: UploadedFile = File(...)):
return {"name": file.name, "data": file.read().decode()}

# Test Client
client = TestClient(api)

response = client.patch("/sync/random-file") # no file
assert response.status_code == 422

response = client.put("/sync/random-file") # no file
assert response.status_code == 422

file = SimpleUploadedFile("django.txt", b"django-rocks")
response = client.patch("/sync/random-file", FILES={"file": file})
assert response.status_code == 200
assert response.json() == {"name": "django.txt", "data": "django-rocks"}

file = SimpleUploadedFile("foo.txt", b"bar")
response = client.put("/sync/random-file", FILES={"file": file})
assert response.status_code == 200
assert response.json() == {"name": "foo.txt", "data": "bar"}


@pytest.mark.asyncio
@override_settings(MIDDLEWARES=("ninja.middlewares.process_put_patch"))
async def test_async_patch_put_middleware():
api = NinjaAPI()

@api.patch("/async/random-file")
async def async_patch_random_file(request, file: UploadedFile = File(...)):
return {"name": file.name, "data": file.read().decode()}

@api.put("/async/random-file")
async def async_put_random_file(request, file: UploadedFile = File(...)):
return {"name": file.name, "data": file.read().decode()}

client = TestAsyncClient(api)

response = await client.patch("/async/random-file") # no file
assert response.status_code == 422

response = await client.put("/async/random-file") # no file
assert response.status_code == 422

file = SimpleUploadedFile("django.txt", b"django-rocks")
response = await client.patch("/async/random-file", FILES={"file": file})
assert response.status_code == 200
assert response.json() == {"name": "django.txt", "data": "django-rocks"}

file = SimpleUploadedFile("foo.txt", b"bar")
response = await client.put("/async/random-file", FILES={"file": file})
assert response.status_code == 200
assert response.json() == {"name": "foo.txt", "data": "bar"}


class TestMiddleware(TestCase):
def setUp(self) -> None:
self.factory = RequestFactory()

def test_middleware(self):
get_response = mock.MagicMock()
request = self.factory.get("/")

middleware = process_put_patch(get_response)
response = middleware(request)

# ensure get_response has been returned
# (or not, if your middleware does something else)
self.assertEqual(get_response.return_value, response)
4 changes: 0 additions & 4 deletions tests/test_orm_schemas.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from typing import List
from unittest.mock import Mock

import django
import pytest
from django.contrib.postgres import fields as ps_fields
from django.db import models
Expand Down Expand Up @@ -205,9 +204,6 @@ class Meta:
}


@pytest.mark.skipif(
django.VERSION < (3, 1), reason="json field introduced in django 3.1"
)
def test_django_31_fields():
class ModelNewFields(models.Model):
jsonfield = models.JSONField()
Expand Down