50

I am trying to write a Django Rest Framework API handler that can receive a file as well as a JSON payload. I've set the MultiPartParser as the handler parser.

However, it seems I cannot do both. If I send the payload with the file as a multi part request, the JSON payload is available in a mangled manner in the request.data (first text part until the first colon as the key, the rest is the data). I can send the parameters in standard form parameters just fine - but the rest of my API accepts JSON payloads and I wanted to be consistent. The request.body cannot be read as it raises *** RawPostDataException: You cannot access body after reading from request's data stream

For example, a file and this payload in the request body:
{"title":"Document Title", "description":"Doc Description"}
Becomes:
<QueryDict: {u'fileUpload': [<InMemoryUploadedFile: 20150504_115355.jpg (image/jpeg)>, <InMemoryUploadedFile: Front end lead.doc (application/msword)>], u'{%22title%22': [u'"Document Title", "description":"Doc Description"}']}>

Is there a way to do this? Can I eat my cake, keep it and not gain any weight?

Edit: It was suggested that this might be a copy of Django REST Framework upload image: "The submitted data was not a file". It is not. The upload and request is done in multipart, and keep in mind the file and upload of it is fine. I can even complete the request with standard form variables. But I want to see if I can get a JSON payload in there instead.

Community
  • 1
  • 1
Harel
  • 1,989
  • 3
  • 26
  • 44
  • possible duplicate of [Django REST Framework upload image: "The submitted data was not a file"](http://stackoverflow.com/questions/28036404/django-rest-framework-upload-image-the-submitted-data-was-not-a-file) – Kevin Brown-Silva May 12 '15 at 00:37
  • 1
    No, its not. edited question to explain why, though I don't even see the similarity between the two questions besides the file upload bit. – Harel May 12 '15 at 00:51
  • 2
    It's important to note that _`application/json` is not the same as `multipart/form-data`_, they can't be used together. And JSON doesn't support file uploading by default, you need to use a custom file field (and base64 encode it) to get file uploading support (which is where the other question comes in). You can't send JSON with multipart data, as multipart can't parse JSON at all and JSON can't parse multipart. – Kevin Brown-Silva May 12 '15 at 00:53
  • I suspected that Kevin but hoped somewhere there's a way to do it. I am not sure base64 won't be too limiting as far as file sizes are concerned and I don't want to put the onus on the client to encode files when sending them. I'll make the api standard multipart then. – Harel May 12 '15 at 08:17
  • possible duplicate of http://stackoverflow.com/questions/32017119/put-request-for-image-upload-not-working-in-django-rest – anjaneyulubatta505 Jan 27 '17 at 10:49

9 Answers9

23

For someone who needs to upload a file and send some data, there is no straight fwd way you can get it to work. There is an open issue in json api specs for this. One possibility i have seen is to use multipart/related as shown here, but i think its very hard to implement it in drf.

Finally what i had implemented was to send the request as formdata. You would send each file as file and all other data as text. Now for sending the data as text you can have a single key called data and send the whole json as string in value.

Models.py

class Posts(models.Model):
    id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    caption = models.TextField(max_length=1000)
    media = models.ImageField(blank=True, default="", upload_to="posts/")
    tags = models.ManyToManyField('Tags', related_name='posts')

serializers.py -> no special changes needed, not showing my serializer here as its too lengthy because of the writable ManyToMany Field implimentation.

views.py

class PostsViewset(viewsets.ModelViewSet):
    serializer_class = PostsSerializer
    parser_classes = (MultipartJsonParser, parsers.JSONParser)
    queryset = Posts.objects.all()
    lookup_field = 'id'

You will need custom parser as shown below for parsing json.

utils.py

from django.http import QueryDict
import json
from rest_framework import parsers

class MultipartJsonParser(parsers.MultiPartParser):

    def parse(self, stream, media_type=None, parser_context=None):
        result = super().parse(
            stream,
            media_type=media_type,
            parser_context=parser_context
        )
        data = {}
        # find the data field and parse it
        data = json.loads(result.data["data"])
        qdict = QueryDict('', mutable=True)
        qdict.update(data)
        return parsers.DataAndFiles(qdict, result.files)

The request example in postman case2

EDIT:

see this extended answer if you want to send each data as key value pair

Nithin
  • 5,470
  • 37
  • 44
  • This answer really helped me with multipart json + file upload. Especially I gave me an idea how to handle problems due to nested dictionary being converted into strings. I had to convert them back to proper json with my customized parser. – Bossam Aug 19 '19 at 09:34
  • can you please upload the picture of postman if media and a data field is foreign key(multiple)? – Sam Aug 21 '19 at 19:23
  • This doesn't work for me. The nested serializer field ('location' in my case) is removed later on, and the API sends the response of 'tags field is required'. I do have the 'location' field in my parser, it just isn't passed on to validate function i think. – Mazhar Ali Jul 27 '21 at 13:46
  • If you still get the 'field is required' error after trying this. You should look at this https://stackoverflow.com/questions/68555559/django-rest-framework-drf-sets-json-field-to-empty-in-the-to-internal-value-fu/68571075#68571075 – Mazhar Ali Jul 29 '21 at 06:30
12

I know this is an old thread, but I just came across this. I had to use MultiPartParser in order to get my file and extra data to come across together. Here's what my code looks like:

# views.py
class FileUploadView(views.APIView):
    parser_classes = (MultiPartParser,)

    def put(self, request, filename, format=None):
        file_obj = request.data['file']
        ftype    = request.data['ftype']
        caption  = request.data['caption']
        # ...
        # do some stuff with uploaded file
        # ...
        return Response(status=204)

My AngularJS code using ng-file-upload is:

file.upload = Upload.upload({
  url: "/api/picture/upload/" + file.name,
  data: {
    file: file,
    ftype: 'final',
    caption: 'This is an image caption'
  }
});
themanatuf
  • 2,880
  • 2
  • 25
  • 39
6

I send JSON and an image to create/update a product object. Below is a create APIView that works for me.

Serializer

class ProductCreateSerializer(serializers.ModelSerializer):
    class Meta:
         model = Product
        fields = [
            "id",
            "product_name",
            "product_description",
            "product_price",
          ]
    def create(self,validated_data):
         return Product.objects.create(**validated_data)

View

from rest_framework  import generics,status
from rest_framework.parsers import FormParser,MultiPartParser

class ProductCreateAPIView(generics.CreateAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductCreateSerializer
    permission_classes = [IsAdminOrIsSelf,]
    parser_classes = (MultiPartParser,FormParser,)

    def perform_create(self,serializer,format=None):
        owner = self.request.user
        if self.request.data.get('image') is not None:
            product_image = self.request.data.get('image')
            serializer.save(owner=owner,product_image=product_image)
        else:
            serializer.save(owner=owner)

Example test:

def test_product_creation_with_image(self):
    url = reverse('products_create_api')
    self.client.login(username='testaccount',password='testaccount')
    data = {
        "product_name" : "Potatoes",
        "product_description" : "Amazing Potatoes",
        "image" : open("local-filename.jpg","rb")
    }
    response = self.client.post(url,data)
    self.assertEqual(response.status_code,status.HTTP_201_CREATED)
Abijith Mg
  • 2,647
  • 21
  • 35
sarc360
  • 349
  • 3
  • 5
4

@Nithin solution works but essentially it means you are sending JSON as strings and hence not using the actual application/json inside the multipart segments.

What we want is to make the backend accept data in the below format

------WebKitFormBoundaryrga771iuUYap8BB2
Content-Disposition: form-data; name="file"; filename="1x1_noexif.jpeg"
Content-Type: image/jpeg


------WebKitFormBoundaryrga771iuUYap8BB2
Content-Disposition: form-data; name="myjson"; filename="blob"
Content-Type: application/json

{"hello":"world"}
------WebKitFormBoundaryrga771iuUYap8BB2
Content-Disposition: form-data; name="isDownscaled"; filename="blob"
Content-Type: application/json

false
------WebKitFormBoundaryrga771iuUYap8BB2--

MultiPartParser works with the above format but will treat those jsons as files. So we simply unmarshal those jsons by putting them to data.

parsers.py

from rest_framework import parsers

class MultiPartJSONParser(parsers.MultiPartParser):
    def parse(self, stream, *args, **kwargs):
        data = super().parse(stream, *args, **kwargs)

        # Any 'File' found having application/json as type will be moved to data
        mutable_data = data.data.copy()
        unmarshaled_blob_names = []
        json_parser = parsers.JSONParser()
        for name, blob in data.files.items():
            if blob.content_type == 'application/json' and name not in data.data:
                mutable_data[name] = json_parser.parse(blob)
                unmarshaled_blob_names.append(name)
        for name in unmarshaled_blob_names:
            del data.files[name]
        data.data = mutable_data

        return data

settings.py

REST_FRAMEWORK = {
    ..
    'DEFAULT_PARSER_CLASSES': [
        ..
        'myproject.parsers.MultiPartJSONParser',
    ],
}

This should work now.

The final bit is testing. Since the test client that ships with Django and REST doesn't support multipart JSON, we work around that by wrapping any JSON data.

import io
import json

def JsonBlob(obj):
    stringified = json.dumps(obj)
    blob = io.StringIO(stringified)
    blob.content_type = 'application/json'
    return blob

def test_simple(client, png_3x3):
    response = client.post(f'http://localhost/files/', {
            'file': png_3x3,
            'metadata': JsonBlob({'lens': 'Sigma 35mm'}),
        }, format='multipart')
    assert response.status_code == 200

Pithikos
  • 18,827
  • 15
  • 113
  • 136
  • Passing a list of data in the Json blob gets caught up in utils/html#parse_html_list. I'll post my attempt at listifying your parser in another answer since I can't do it in a comment. But first let me say thanks for your answer. Extremely helpful! – David Axelrod Mar 11 '21 at 06:44
3

It is very simple to use a multipart post and a regular view, if this is an option.

You send the json as a field and files as files, then process in one view.

Here is a simple python client and a Django server:

Client - sending multiple files and an arbitrary json-encoded object:

import json
import requests

payload = {
    "field1": 1,
    "manifest": "special cakes",
    "nested": {"arbitrary":1, "object":[1,2,3]},
    "hello": "word" }

filenames = ["file1","file2"]
request_files = {}
url="example.com/upload"

for filename in filenames:
    request_files[filename] = open(filename, 'rb')

r = requests.post(url, data={'json':json.dumps(payload)}, files=request_files)

Server - consuming the json and saving the files:

@csrf_exempt
def upload(request):
    if request.method == 'POST':
        data = json.loads(request.POST['json']) 
        try:
            manifest = data['manifest']
            #process the json data

        except KeyError:
            HttpResponseServerError("Malformed data!")

        dir = os.path.join(settings.MEDIA_ROOT, "uploads")
        os.makedirs(dir, exist_ok=True)

        for file in request.FILES:
            path = os.path.join(dir,file)
            if not os.path.exists(path):
                save_uploaded_file(path, request.FILES[file])           

    else:
        return HttpResponseNotFound()

    return HttpResponse("Got json data")


def save_uploaded_file(path,f):
    with open(path, 'wb+') as destination:
        for chunk in f.chunks():
            destination.write(chunk)
QT-1
  • 900
  • 14
  • 21
3

If you're getting an error along the lines of Incorrect type. Expected pk value, received list., with @nithin's solution, it's because Django's QueryDict is getting in the way - it's specifically structured to use a list for each entry in the dictionary, and thus:

{ "list": [1, 2] }

when parsed by MultipartJsonParser yields

{ 'list': [[1, 2]] }

which trips up your serializer.

Here is an alternative which handles this case, specifically expecting the _data key for your JSON:

from rest_framework import parsers
import json

class MultiPartJSONParser(parsers.MultiPartParser):
    def parse(self, stream, *args, **kwargs):
        data = super().parse(stream, *args, **kwargs)
        json_data_field = data.data.get('_data')
        if json_data_field is not None:
            parsed = json.loads(json_data_field)
            mutable_data = {}
            for key, value in parsed.items():
                mutable_data[key] = value
            mutable_files = {}
            for key, value in data.files.items():
                if key != '_data':
                    mutable_files[key] = value
            return parsers.DataAndFiles(mutable_data, mutable_files)

        json_data_file = data.files.get('_data')
        if json_data_file:
            parsed = parsers.JSONParser().parse(json_data_file)
            mutable_data = {}
            for key, value in parsed.items():
                mutable_data[key] = value
            mutable_files = {}
            for key, value in data.files.items():
                mutable_files[key] = value
            return parsers.DataAndFiles(mutable_data, mutable_files)

        return data
Mad Scientist Moses
  • 1,577
  • 1
  • 12
  • 11
1

I'd just like to add to @Pithikos's answer by modifying the parser to accept lists as well, in line with how DRF parses lists in serializers in utils/html#parse_html_list

class MultiPartJSONParser(parsers.MultiPartParser):
    def parse(self, stream, *args, **kwargs):
        data = super().parse(stream, *args, **kwargs)

        # Any 'File' found having application/json as type will be moved to data
        mutable_data = data.data.copy()
        unmarshaled_blob_names = []
        json_parser = parsers.JSONParser()
        for name, blob in data.files.items():
            if blob.content_type == 'application/json' and name not in data.data:
                parsed = json_parser.parse(blob)
                if isinstance(parsed, list):
                    # need to break it out into [0], [1] etc
                    for idx, item in enumerate(parsed):
                        mutable_data[name+f"[{str(idx)}]"] = item
                else:
                    mutable_data[name] = parsed
                unmarshaled_blob_names.append(name)
        for name in unmarshaled_blob_names:
            del data.files[name]
        data.data = mutable_data

        return data
David Axelrod
  • 76
  • 4
  • 9
0

The following code worked for me.

from django.core.files.uploadedfile import SimpleUploadedFile
import requests
from typing import Dict

with open(file_path, 'rb') as f:
    file = SimpleUploadedFile('Your-Name', f.read())

    data: Dict[str,str]
    files: Dict[str,SimpleUploadedFile] = {'model_field_name': file}

    requests.put(url, headers=headers, data=data, files=files)
    requests.post(url, headers=headers, data=data, files=files)

'model_field_name' is the name of the FileField or ImageField in your Django model. You can pass other data as name or location as usual by using data parameter.

Hope this helps.

Tobias Ernst
  • 4,214
  • 1
  • 32
  • 30
0

This work for me:

class FileUpload(APIView):
    parser_classes = [MultiPartParser]
    authentication_classes = [JWTAuthentication]

    def post(self, request, filename, format=None):
       file = request.data['file']
       data = json.loads(request.POST['form'])
       #.... just do.....

. . .

frontend part: example with fetch (vue frontend)

let data = await new FormData(); // creates a new FormData object
data.append("file", this.files); // add your file to form data
data.append('form',JSON.stringify(body)) //add your json

fetch(`https://endpoint/FileUpload/${body.nombre}`, {
  method: 'POST',
  body: data, 
  headers: {Authorization: `Bearer ${accessToken}`}
              })

I hope this helps.

andresd27
  • 1
  • 1