vitalik / django-ninja

💨 Fast, Async-ready, Openapi, type hints based framework for building APIs
https://django-ninja.dev
MIT License
7.33k stars 435 forks source link

[BUG] JSON payload not parsed when using upload with extra fields #1306

Open matt0x6F opened 1 month ago

matt0x6F commented 1 month ago

Describe the bug Ninja fails to parse the request body correctly and throws a validation error, contrary to the instructions under Upload files with extra fields.

Versions (please complete the following information):

Relevant schema

class FileMetadata(Schema):
    posts: List[int] = []
    visibility: str = "public"

API Code

@files_router.post(
    "/", response={200: FileDetails}, tags=["files"], auth=JWTAuth(permissions=StaffOnly)
)
def create_file(request: HttpRequest, metadata: FileMetadata, upload: NinjaFile[UploadedFile]):
    """
    Creates a file with or without post associations.
    """
    try:
        if metadata.visibility == "public":
            stored_name = PublicStorage().save(upload.name, upload.file)

            url = PublicStorage().url(stored_name)
        else:
            stored_name = PrivateStorage().save(upload.name, upload.file)

            url = PrivateStorage().url(stored_name)

        upload = File.objects.create(
            location=url,
            name=stored_name,
            content_type=upload.content_type,
            charset=upload.charset,
            size=upload.size,
            visibility=metadata.visibility,
        )

        if metadata:
            upload.posts.set(metadata.posts)

        return upload
    except Exception as err:
        logger.error("Error creating file", error=err)

        raise HttpError(500, "Fail to create file") from err

Request body

-----------------------------291645760626718691221248293984
Content-Disposition: form-data; name="upload"; filename="business card front.pdf"
Content-Type: application/pdf

file stuff
%%EOF

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

{"posts":[2,4],"visibility":"public"}
-----------------------------291645760626718691221248293984--

Content-Type on the request is set to multipart/form-data; boundary=---------------------------291645760626718691221248293984

Response

{
    "detail": [
        {
            "type": "missing",
            "loc": [
                "body",
                "metadata"
            ],
            "msg": "Field required"
        }
    ]
}
matt0x6F commented 1 month ago

To disambiguate some code here:

matt0x6F commented 1 month ago

For some extra context I use openapi-generator-cli to generate my TypeScript SDK using typescript-fetch. I can supply the code around that if it helps at all.

matt0x6F commented 1 month ago

Just to double check I console.log'd what I'm sending

image

I changed it to use Form around the schema and that got me a little bit farther but it still doesn't parse correctly.

matt0x6F commented 1 month ago

I was able to successfully make the request go through via the OpenAPI docs page. The payload looks a lot different:

-----------------------------35115467084627556252850040527
Content-Disposition: form-data; name="upload"; filename="business card back.pdf"
Content-Type: application/pdf

file stuff
%%EOF

-----------------------------35115467084627556252850040527
Content-Disposition: form-data; name="metadata"

{
  "posts": [
    4, 2
  ],
  "visibility": "public"
}
-----------------------------35115467084627556252850040527--

I think that means that the typescript-fetch plugin for openapi-generator-cli may be the problem. It sends an extra Content-Type: application/json and filename="blob"

matt0x6F commented 1 month ago

Yeap, and found the bug on openapi-generator that's been open since 2020

matt0x6F commented 1 month ago

so, they do this in every generator plugin. I think that's what's messing up the parsing and typing here.

0nliner commented 1 month ago

I have same problem, but I am not using openapi-generator

0nliner commented 1 month ago

I tried sending the request not through my application, but through Postman. I noticed that in the request that is processed correctly, the Content-Type header contains a boundary (an arbitrary string that defines the boundaries of the content).

b'-----------------------------409722013720178429992086406357\r\nContent-Disposition: form-data; name="file"; filename="\xd0\x9a\xd0\xbe\xd0\xbd\xd1\x82\xd0\xb8\xd0\xbd\xd0\xb5\xd0\xbd\xd1\x82\xd0\xb0\xd0\xbb\xd1\x8c (2).xls"\r\nContent-Type: application/vnd.ms-excel\r\n\r\n\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1\x00\x00\x00\x00\x00...

In this case, -----------------------------409722013720178429992086406357 is our boundary, which can be found at the beginning and end of the raw request.

The method django.http.multipartparser.MultiPartParser.init looks for the boundary in the Content-Type and expects the following format in the request headers: 'Content-Type': 'multipart/form-data; boundary=---------------------------409722013720178429992086406357'

When explicitly setting a value for Content-Type, using fetch does not automatically substitute the boundary value.