anymaniax / orval

orval is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in yaml or json formats. 🍺
https://orval.dev
MIT License
2.57k stars 286 forks source link

`MediaType` is not encoded properly in `multipart/form-data` (`application/octet-stream` is not supported) #869

Open usersina opened 1 year ago

usersina commented 1 year ago

What are the steps to reproduce this issue?

Given the following schema, generate the client types

openapi: 3.0.1
info:
  title: OpenAPI definition
  version: v0
servers:
  - url: 'http://localhost:8080'
    description: Generated server url
paths:
  /test:
    post:
      tags:
        - hello-controller
      operationId: saveDocu2ment
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                personDTO:
                  $ref: '#/components/schemas/PersonDTO'
                file:
                  type: string
                  format: binary
            encoding:
              personDTO:
                contentType: application/json
      responses:
        '200':
          description: OK
components:
  schemas:
    PersonDTO:
      type: object
      properties:
        email:
          type: string
        firstName:
          type: string
        lastName:
          type: string

What happens?

For the upload function using client: "react-query", this is what's generated

export const useUploadFilesHook = () => {
  const uploadFiles = useCustomInstance<UploadFiles200>();

  return (uploadFilesBody: UploadFilesBody) => {
    const formData = new FormData();
    formData.append("personDTO", JSON.stringify(uploadFilesBody.personDTO));
    formData.append("file", uploadFilesBody.file);

    return uploadFiles({  
      url: "/test",
      method: "post",
      headers: { "Content-Type": "multipart/form-data" },
      data: formData,
    });
  };
};

However, this is broken and will result in the following error

{
  "status": 415,
  "error": "Unsupported Media Type",
  "message": "Content type 'application/octet-stream' not supported"
}

The reason for this, is that the personDTO field is expected to be encoded in application/json and not in the default application/octet-stream.

What were you expecting to happen?

Instead, this is what works if I manually update the file

export const useUploadFilesHook = () => {
  const uploadFiles = useCustomInstance<UploadFiles200>();

  return (uploadFilesBody: UploadFilesBody) => {
    const formData = new FormData();
    formData.append("personDTO", new Blob([JSON.stringify(uploadFilesBody.personDTO)], { type: "application/json" }));
    formData.append("file", uploadFilesBody.file);

    return uploadFiles({  
      url: "/test",
      method: "post",
      headers: { "Content-Type": "multipart/form-data" },
      data: formData,
    });
  };
};

Any other comments?

This issue is very closely related.

What versions are you using?

Package Version: ^6.15.0

Hypenate commented 9 months ago

We have the exact same issue and it's a blocker at the moment. Were you able to omit this issue?

In our .NET backend we have this endpoint: Upload([FromForm] IFormFile file)

Which creates an open API like this:

requestBody:
   content:
     multipart/form-data:

Orval generates this:

 uploadFile<TData = xxxResponseDto | ProblemDetailsDto>(
    postFileBody: PostFileBodyOne | PostFileBodyTwo, options?: HttpClientOptions
  ): Observable<TData>  {
    return this.http.post<TData>(
      `/upload/validate`,
      postFileBody,options
    );
  }
};

But it should generate this:

  uploadFile<TData = xxxResponseDto | ProblemDetailsDto>(
    postFileBody: FormData,
    options?: HttpClientOptions
  ): Observable<TData> {
    return this.http.post<TData>(`/upload/validate`, postFileBody, options);
  }

Than we would be able use this code in the frontend to upload the file:

  uploadFile(file: File): Observable<void | xxxResponseDto | ProblemDetailsDto> {
    const formData: FormData = new FormData();
    formData.append('myFile', file, file.name);

    return this.service.postFile(formData);
  }

Howerver, with the current implementation in Orval, I would have to create this code in the frontend, the code is a lot more verbose and there is also an issue with the Boundary header which makes this call invalid:

  const postFileBody: PostFileBodyOne = {
      ContentType: 'application/pdf', 
      ContentDisposition: 'attachment', 
      Length: file.size, 
      Name: 'file',
      FileName: file.name,
    };

    return this.service
      .postFile(postFileBody, {
        headers: {
          'Content-Type': 'multipart/form-data',
          'Boundary': <-- calculate valued, eg: --------------------------e8c5c0c3e9f5c5a0
        },
      })
      );
usersina commented 9 months ago

I use a workaround of setting the frontend input to a Blob, hence removing any validation there. I do make sure to validate backend wise though:

Having the following schema:

{
  ...
  "requestBody": {
    "content": {
      "multipart/form-data": {
        "schema": {
          "required": ["files", "projectFileRequest"],
          "type": "object",
          "properties": {
            "projectFileRequest": {
              "type": "string",
              "description": "Request part containing the file metadata (Temporarily set to a BLOB until parsing issue is fixed. Server validation works though)",
              "format": "binary"
            },
            "files": {
              "type": "array",
              "items": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        }
      }
    }
  }
  ...
}

I then proceed to call it like so, in a react app:

const uploadFilesMutation = useUploadPacketFiles();

const handleUpload = (newFiles: File[]) => {
  uploadFilesMutation.mutate(
    {
      data: {
        files: newFiles,
        projectFileRequest: new Blob(
          [
            JSON.stringify({
              projectId: "project.id",
              creatorId: "user.id",
            }),
          ],
          { type: "application/json" },
        ),
      },
    },
    {
      onSuccess(results) {
        console.log("Uploaded file ids", results);
      },
    },
  );
};
Hypenate commented 9 months ago

Things get interesting when I take a look the Opeqsdfn API that is being generated. If you take a look at the specifications by Swagger

requestBody:
  content:
    image/png:
      schema:
        type: string
        format: binary

then Orval is able to correctly generate it.

So I guess I must find a way to make my .NET backend spit this out, instead of some nonesence 😅

ruiaraujo012 commented 3 months ago

I had the same problem and I fixed it by doing this:

orval.config.ts:

import { defineConfig } from 'orval';

export default defineConfig({
  cresoPlus: {
    input: {
      target: './docs/api.yaml',
    },
    output: {
      clean: true,
      client: 'react-query',
      mock: false,
      mode: 'tags',
      override: {
        formData: {
          name: 'customFormDataFn',
          path: './src/api/overrides/formData.ts',
        },
        mutator: {
          name: 'customAxiosInstance',
          path: './src/lib/axios.ts',
        },
      },
      packageJson: './package.json',
      prettier: true,
      schemas: './src/api/generated/models',
      target: './src/api/generated',
      tsconfig: './tsconfig.json',
    },
  },
});

./src/api/overrides/formData.ts:

export const customFormDataFn = <Body extends object>(body: Body): FormData => {
  const formData = new FormData();

  Object.entries(body).forEach(([key, value]) => {
    if (value !== undefined && value !== null) {
      if (value instanceof Blob) {
        formData.append(key, value);
      } else {
        formData.append(
          key,
          new Blob([JSON.stringify(value)], {
            type: 'application/json',
          }),
        );
      }
    }
  });

  return formData;
};