swift-openapi-generator icon indicating copy to clipboard operation
swift-openapi-generator copied to clipboard

How to send a file in multipart/form-data?

Open yousifalraheem opened this issue 11 months ago • 5 comments

Question

I'm using your package to generate my APIs which has been working great for me. However, as soon as I started working with uploading files, I stumbled upon an issue with multipart/form-data. Whenever I use the generated API, it sends the request with the file having a mimetype of application/octet-stream. Which is something that I am not supporting on the backend nor do I have plans to do so. If I'm uploading a jpeg image, I expect the mimetype to be image/jpeg. How do I get this desired behavior?

This is how I create data from a UIImage:

func imageToMultipartData(_ image: UIImage) async throws -> Data {
  let boundary = UUID().uuidString
  
  var data = Data()
  data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
  data.append("Content-Disposition: form-data; name=\"file\"; filename=\"image.jpeg\"\r\n".data(using: .utf8)!)
  data.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
  data.append(image.jpegData(compressionQuality: 1.0)!)
  data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
  
  return data
}

This is how I'm using the generated API:

guard let image = viewModel.selectedImage else {
  throw URLError(.cannotDecodeRawData)
}
let data = try await imageToMultipartData(image)
let response = try await HttpClient.updateProfileImage(
  body: .multipartForm([
    .file(.init(
      payload: .init(body: HTTPBody(data)),
      filename: "file.jpeg"
    ))
  ])
)

This is my schema for the method:

/users/profile/image:
  post:
    operationId: updateProfileImage
    parameters: []
    requestBody:
      required: true
      content:
        multipart/form-data:
          schema:
            $ref: '#/components/schemas/UploadImageDto'
    responses:
      '201':
        description: ''
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserProfileDto'
      '400':
        description: Bad request
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ValidationErrorDto'

Where UploadImageDto component have this schema:

UploadImageDto:
  type: object
  properties:
    file:
      type: string
      format: binary
  required:
    - file

yousifalraheem avatar Mar 09 '24 13:03 yousifalraheem

Hey @yousifalraheem. We have an example package for client and server that show how to handle various request and response content types, including multipart here: https://github.com/apple/swift-openapi-generator/tree/main/Examples/various-content-types-client-example.

If that doesn't help, let us know and we can take a closer look at your use case specifically.

simonjbeaumont avatar Mar 09 '24 16:03 simonjbeaumont

Hi @yousifalraheem,

it seems you're manually serializing the multipart part, but that's not correct - the generated code already does that. You need to simplify your Data creation to just:

func imageToMultipartData(_ image: UIImage) async throws -> Data {
  image.jpegData(compressionQuality: 1.0)!
}

czechboy0 avatar Mar 09 '24 21:03 czechboy0

Hi @yousifalraheem - were you able to get it working?

czechboy0 avatar Apr 16 '24 11:04 czechboy0

@czechboy0 .. I failed with every attempt to use the generated function so I ended up with manually creating the entire HTTP request with URLSession which is unfortunate. My upload function and its utility looked something like this:

func imageToMultipartData(_ image: UIImage, boundary: String, fieldName: String = "file") async throws -> Data {
    let ext = ".jpeg"
    let mimeType = "image/jpeg"
    var data = Data()
    
    data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
    data.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"image\(ext)\"\r\n".data(using: .utf8)!)
    data.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
    data.append(image.jpegData(compressionQuality: 1.0)!)
    data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
    
    return data
}

func uploadProfileImage() async {
    do {
      guard let image = viewModel.selectedImage else {
        return
      }
      let boundary = UUID().uuidString
      let url = URL(string: "https://mysite.com/users/profile/image")!
      
      var request = URLRequest(url: url)
      request.httpMethod = "POST"
      request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
      request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
      
      let data = try await imageToMultipartData(image, boundary: boundary)
      
      let (_, _) = try await URLSession.shared.upload(for: request, from: data)
      viewModel.imageSelection = nil
    } catch {
      uploadError = error.localizedDescription
    }
}

It's not ideal but at least it worked. I believe this complexity could've been avoided. Maybe an overload init function can be added that accepts just a UIImage instead of HTTPBody and it would deal with creating the multipart request correctly using the schema which clearly specifies the property name and type. When I built the web app for the same platform, the generated upload function accepts a value of type File and it would do the rest flawlessly. If I would suggest a feature, it would be to simplify this process if possible.

yousifalraheem avatar Jun 29 '24 21:06 yousifalraheem

You create an HTTPBody from a UIImage like this:

let image: UIImage = ...
let body = HTTPBody(image.jpegData(compressionQuality: 1.0)!)

And then you create the file part with this data, and you can specify the content disposition fields, like filename as well.

If you could clarify which parts weren't working for you, we can try to help.

czechboy0 avatar Jul 01 '24 06:07 czechboy0

Closing, please reopen if you think there's a bug in Swift OpenAPI Generator.

czechboy0 avatar Oct 29 '24 09:10 czechboy0