Search code examples
swiftfile-uploadmultipartform-datavapor

How to upload files for a model using vapor 3 for an iOS app


I want to make an iOS app using Vapor 3 as my backend. The model I am creating to represent my object contains some properties that will be files such as a .png and .plist files. I'm having trouble understanding how to use multipart in order to grab those files and send them to my model endpoint whenever I do a POST request.

I'm also confused on what data type I should be setting those file properties to be in my Model class. In the multipart docs (https://docs.vapor.codes/3.0/multipart/overview/#content) under the "Content" section, they say to make a Struct and they set their image to be of type Data, but also say you can make it of type File. I've also seen examples where they make it of type String.

I was hoping someone could clarify what exactly I should set those properties' data types to be and how I could upload those files in my Controllers/ModelController where I do the saving and call .post() in my boot(router: Router) function

I have already looked through the Multipart vapor docs and read through these stackoverflow posts but still don't understand what I should be doing when I try to use the post method: - Vapor upload multiple files at once - How to handle multipart request with Vapor 3 - Image Upload in Vapor 3 using PostgreSQL

This is my model class:

import Vapor
import FluentMySQL

final class AppObject: Codable {
  var id: Int?
  var plistFile: String // file
  var imageFile: String // file
  var notes: String
  var name: String

  init(ipaFile: String, plistFile: String, imageFile: String, notes: String, name: String) {
    self.ipaFile = ipaFile
    self.plistFile = plistFile
    self.imageFile = imageFile
    self.notes = notes
    self.name = name
  }
}

extension AppObject: MySQLModel {}
extension AppObject: Content {}
extension AppObject: Migration {}
extension AppObject: Parameter {}

This is my Controller for the above model:

import Vapor
import Fluent

struct AppObjectsController: RouteCollection {


    func boot(router: Router) throws {
        let appObjectsRoute = router.grouped("api", "apps")
        appObjectsRoute.get(use: getAllHandler)
        appObjectsRoute.post(AppObject.self, use: createHandler)
    }

    func getAllHandler(_ req: Request) throws -> Future<[AppObject]> {
        return AppObject.query(on: req).all()
    }

    // what else should I be doing here in order to upload actual files?
    func createHandler(_ req: Request, appobject: AppObject) throws -> Future<AppObject> {
         return appobject.save(on: req)
    }
}

some of the examples i've seen deal with uploading for a web app and they return a Future< View > but since I am doing an iOS app, I don't know if I should be returning an HTTPResponseStatus or my model object.

Please help, I tried my best to word this well, I am new to Vapor


Solution

  • Server-side

    Model

    final class AppObject: Codable {
        var id: Int?
        var ipaFile: String // relative path to file in Public dir
        var plistFile: String // relative path to file in Public dir
        var imageFile: String // relative path to file in Public dir
        var notes: String
        var name: String
    
        init(ipaFile: String, plistFile: String, imageFile: String, notes: String, name: String) {
            self.ipaFile = ipaFile
            self.plistFile = plistFile
            self.imageFile = imageFile
            self.notes = notes
            self.name = name
        }
    }
    
    extension AppObject: MySQLModel {}
    extension AppObject: Content {}
    extension AppObject: Migration {}
    extension AppObject: Parameter {}
    

    Controller

    struct AppObjectsController: RouteCollection {
        func boot(router: Router) throws {
            let appObjectsRoute = router.grouped("api", "apps")
            appObjectsRoute.get(use: getAllHandler)
            appObjectsRoute.post(PostData.self, use: createHandler)
        }
    
        func getAllHandler(_ req: Request) throws -> Future<[AppObject]> {
            return AppObject.query(on: req).all()
        }
    }
    
    extension AppObjectsController {
        struct PostData: Content {
            let ipaFile, plistFile, imageFile: File
            let name, notes: String
        }
    
        func createHandler(_ req: Request, payload: PostData) throws -> Future<AppObject> {
            let ipaFile = ServerFile(ext: "ipa", folder: .ipa)
            let plistFile = ServerFile(ext: "plist", folder: .plist)
            let imageFile = ServerFile(ext: "jpg", folder: .image)
            let appObject = AppObject(ipaFile: ipaFile.relativePath, plistFile: plistFile.relativePath, imageFile: imageFile.relativePath, notes: payload.notes, name: payload.name)
            /// we have to wrap it in transaction
            /// to rollback object creating
            /// in case if file saving fails
            return req.transaction(on: .mysql) { conn in
                return appObject.create(on: conn).map { appObject in
                    try ipaFile.save(with: payload.ipaFile.data)
                    try plistFile.save(with: payload.plistFile.data)
                    try imageFile.save(with: payload.imageFile.data)
                }
            }
        }
    }
    

    ServerFile struct

    struct ServerFile {
        enum Folder: String {
            case ipa = "ipa"
            case plist = "plists"
            case image = "images"
            case root = ""
        }
    
        let file, ext: String
        let folder: Folder
    
        init (file: String? = UUID().uuidString, ext: String, folder: Folder? = .root) {
            self.file = file
            self.ext = ext
            self.folder = folder
        }
    
        var relativePath: String {
            guard folder != .root else { return fileWithExt }
            return folder.rawValue + "/" + fileWithExt
        }
    
        var fileWithExt: String { return file + "." + ext }
    
        func save(with data: Data) throws {
            /// Get path to project's dir
            let workDir = DirectoryConfig.detect().workDir
            /// Build path to Public folder
            let publicDir = workDir.appending("Public")
            /// Build path to file folder
            let fileFolder = publicDir + "/" + folder.rawValue
            /// Create file folder if needed
            var isDir : ObjCBool = true
            if !FileManager.default.fileExists(atPath: fileFolder, isDirectory: &isDir) {
                try FileManager.default.createDirectory(atPath: fileFolder, withIntermediateDirectories: true)
            }
            let filePath = publicDir + "/" + relativePath
            /// Save data into file
            try data.write(to: URL(fileURLWithPath: filePath))
        }
    }
    

    iOS

    Declare AppObject model

    struct AppObject: Codable {
        var id: Int
        var ipaFile, plistFile, imageFile: String
        var name, notes: String
    }
    

    With CodyFire library multipart requests are really easy

    Declare you endpoint

    import CodyFire
    
    struct AppController: EndpointController {
        static var server: ServerURL? = nil
        static var endpoint: String = "apps"
    }
    
    /// Usually separate file like App+Create.swift
    extension AppController {
        struct CreateAppRequest: MultipartPayload {
            var ipaFile, plistFile, imageFile: Attachment
            var name, note: String
            public init (ipaFile: Attachment, plistFile: Attachment, imageFile: Attachment, name: String, note: String) {
                self.ipaFile = ipaFile
                self.plistFile = plistFile
                self.imageFile = imageFile
                self.name = name
                self.note = note
            }
        }
    
        static func create(_ payload: CreateAppRequest) -> APIRequest<AppObject> {
            return request(payload: payload).method(.post)
        }
    }
    

    Then in some view controller try to create an app on the server

    /// Replace _ with file data
    let ipaFile = Attachment(data: _, fileName: "", mimeType: "ipa")
    let plistFile = Attachment(data: _, fileName: "", mimeType: "plist")
    let imageFile = Attachment(data: _, fileName: "", mimeType: .jpg)
    
    let payload = AppController.CreateAppRequest(ipaFile: ipaFile, 
                                                 plistFile: plistFile,
                                                 imageFile: imageFile,
                                                 name: "something", 
                                                 note: "something")
    
    AppController.create(payload).onRequestStarted {
        /// it calls only if request started properly
        /// start showing loading bar
    }.onError { error in
        let alert = UIAlertController(title: nil, message: error.description, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .cancel))
        self.present(alert, animated: true)
    }.onProgress { progress in
        /// show progress
    }.onSuccess { appObject in
        /// show success
        /// here you received just created `appObject`
    }
    

    And that's it, it just works :)

    Next example for getting list of AppObjects

    /// Separate file like App+List.swift
    extension AppController {
        static func list() -> APIRequest<[AppObject]> {
            return request()
        }
    }
    

    then somewhere in view controller

    AppController.list().onSuccess { appObjects in
        /// `appObjects` is `[AppObject]`
    }
    

    Hope it helps.