Let’s start from scratch and build our solution block by block. Apple did a good job of adding Combine publishers everywhere across their system frameworks. Unfortunately, URLSession
only provides
func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher
which can be used for regular requests but is not sufficient for file uploads. Instead, we will use a standard API and wrap it into a publisher to make it compatible with Combine pipelines.
func uploadTask(
with request: URLRequest,
fromFile fileURL: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void
) -> URLSessionUploadTask
Please note that you can also go for another method that uses Data
as opposed to URL
; however, this approach is not recommended since the whole file will be kept in memory, increasing the application footprint. This can have a negative impact on performance, especially in case of larger files like videos. We will create a dreaded APIManager
to demonstrate the solution — but feel free to experiment!
class APIManager: NSObject {
func upload(request: URLRequest, fileURL: URL) -> ?
}
What is the return type, you might ask? Well, there are three things we need to track: Successful upload, its progress and errors.
One option will be to go for something like (AnyPublisher<Data?, Error>, AnyPublisher<Double, Never>)
. Despite allowing some flexibility with the individual publishers, I prefer something more elegant — AnyPublisher<UploadResponse, Error>
— where the response slightly resembles a Swift result type.
enum UploadResponse {
case progress(percentage: Double)
case response(data: Data?)
}
Now we are able to tackle the uploading part.
class APIManager: NSObject {
func upload(
request: URLRequest,
fileURL: URL
) -> AnyPublisher<UploadResponse, Error> {
let subject: PassthroughSubject<UploadResponse, Error> = .init()
let task: URLSessionUploadTask = URLSession.shared.uploadTask(
with: request,
fromFile: fileURL
) { data, response, error in
if let error = error {
subject.send(completion: .failure(error))
return
}
if (response as? HTTPURLResponse)?.statusCode == 200 {
subject.send(.response(data: data))
return
}
subject.send(.response(data: nil))
}
task.resume()
return subject.eraseToAnyPublisher()
}
}
Basically, we will create a PassthroughSubject
or a Future
that will emit events once a completion closure is triggered. Of course, response and error handling may vary based on the actual API. Finally, we should take care of progress updates during our upload. Apple provides URLSessionTaskDelegate
to observe those changes.
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64, totalBytesExpectedToSend: Int64
)
Let's create a custom URLSession
and a publisher in order to track the progress.
let progress: PassthroughSubject<(id: Int, progress: Double), Never> = .init()
lazy var session: URLSession = {
.init(configuration: .default, delegate: self, delegateQueue: nil)
}()
We will also wire it up to the delegate, like this.
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64
) {
progress.send((
id: task.taskIdentifier,
progress: task.progress.fractionCompleted
))
}
The only thing still missing is to connect both flows together. Gladly, Combine allows us to do it almost effortlessly.
return progress
// Otherwise, we will receive events from all the current tasks
.filter{ $0.id == task.taskIdentifier }
.setFailureType(to: Error.self) // Just to make the compiler happy :)
.map { .progress(percentage: $0.progress) }
.merge(with: subject)
.eraseToAnyPublisher()
Here is the final solution handling file uploads and tracking their progress at the same time.
class APIManager: NSObject {
let progress: PassthroughSubject<(id: Int, progress: Double), Never> = .init()
lazy var session: URLSession = {
.init(configuration: .default, delegate: self, delegateQueue: nil)
}()
func upload(
request: URLRequest,
fileURL: URL
) -> AnyPublisher<UploadResponse, Error> {
let subject: PassthroughSubject<UploadResponse, Error> = .init()
let task: URLSessionUploadTask = session.uploadTask(
with: request,
fromFile: fileURL
) { data, response, error in
if let error = error {
subject.send(completion: .failure(error))
return
}
if (response as? HTTPURLResponse)?.statusCode == 200 {
subject.send(.response(data: data))
return
}
subject.send(.response(data: nil))
}
task.resume()
return progress
.filter{ $0.id == task.taskIdentifier }
.setFailureType(to: Error.self)
.map { .progress(percentage: $0.progress) }
.merge(with: subject)
.eraseToAnyPublisher()
}
}
extension APIManager: URLSessionTaskDelegate {
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64
) {
progress.send((
id: task.taskIdentifier,
progress: task.progress.fractionCompleted
))
}
}
Now you are able to integrate this uploading publisher into any of the Combine flows. Thank you for sticking with me until the end!
apiManager.upload(request: request, fileURL: url)
.receive(on: OperationQueue.main)
.sink(receiveCompletion: { completion in
if case let .failure(error) = completion {
// Handle error
}
}) { uploadResponse in
switch uploadResponse {
case let .progress(percentage):
// Handle progress
case let .response(data):
// Handle response
}
}
P.S. Don't forget to use .receive(on: OperationQueue.main)
after subscribing to this publisher, since changing progress will most likely result in UI updates. :)