Using URLSession in Combine

I'm trying to figure out how to use URLSession with the Combine framework.

I have a class that is to fetch data as follows.

import UIKit
import Combine

class APIClient: NSObject {
	var cancellables = [AnyCancellable]()
	@Published var models = [MyModel]()
	
	
	func fetchData(urlStr: String) -> AnyPublisher<[MyModel], Never> {
		guard let url = URL(string: urlStr) else {
			let subject = CurrentValueSubject<[MyModel], Never>([])
			return subject.eraseToAnyPublisher()
		}
		
		let subject = CurrentValueSubject<[MyModel], Never>(models)
		URLSession.shared.dataTaskPublisher(for: url)
			.map { $0.data }
			.decode(type: [MyModel].self, decoder: JSONDecoder())
			.replaceError(with: [])
			.sink { posts in
				print("api client: \(posts.count)")
				self.models = posts
			}
			.store(in: &cancellables)
		return subject.eraseToAnyPublisher()
	}
}

I then have a view model class that is to deliver data for my view controller as follows.

import Foundation
import Combine

class ViewModel: NSObject {
	@IBOutlet var apiClient: APIClient!
	var cancellables = Set<AnyCancellable>()
	@Published var dataModels = [MyModel]()
	
	
	func getGitData() -> AnyPublisher<[MyModel], Never> {
		let urlStr = "https://api.github.com/repos/ReactiveX/RxSwift/events"
		let subject = CurrentValueSubject<[MyModel], Never>(dataModels)
		apiClient.fetchData(urlStr: urlStr)
			.sink { result in
				print("view model: \(result.count)")
				self.dataModels = result
			}.store(in: &cancellables)
		return subject.eraseToAnyPublisher()
	}
}

My view controller has an IBOutlet of ViewModel.

import UIKit
import Combine

class ViewController: UIViewController {
	// MARK: - Variables
	var cancellables = [AnyCancellable]()
	@IBOutlet var viewModel: ViewModel!
	
	
	// MARK: - IBOutlet
	@IBOutlet weak var tableView: UITableView!
	
	
	// MARK: - Life cycle
	override func viewDidLoad() {
		super.viewDidLoad()
		
		viewModel.getGitData()
			.sink { posts in
				print("view controller: \(posts.count)")
			}
			.store(in: &cancellables)
	}
}

If I run it, it seems that ViewModel returns 0 without waiting for APIClient to return data. And the view controller doesn't wait, either. What am I doing wrong? Can I do it without using the completion handler?

In case you need to know what MyModel is, it's a simple struct.

struct MyModel: Decodable {
	let id: String
	let type: String
}

Muchos thanks

Answered by Tomato in 693360022

Okay... The solution to my question is use of the Future guy.

class APIClient: NSObject {
	var cancellables = [AnyCancellable]()
	@Published var models = [MyModel]()
		
	func fetchData(urlStr: String) -> Future<[MyModel], Error> {
		return Future<[MyModel], Error> { [weak self] promise in
			guard let url = URL(string: urlStr) else {
				return promise(.failure("Failure" as! Error))
			}
			guard let strongSelf = self else { return }
			URLSession.shared.dataTaskPublisher(for: url)
			 .map { $0.data }
			 .decode(type: [MyModel].self, decoder: JSONDecoder())
			 .replaceError(with: [])
			 .sink { completion in
				 if case .failure(let error) = completion {
					 promise(.failure(error))
				 }
			 } receiveValue: { promise(.success($0)) }
			 .store(in: &strongSelf.cancellables)
		}
	}
}
class ViewModel: NSObject {
	@IBOutlet var apiClient: APIClient!
	var cancellables = Set<AnyCancellable>()
	@Published var dataModels = [MyModel]()
		
	func getGitData() -> Future<[MyModel], Error> {
		let urlStr = "https://api.github.com/repos/ReactiveX/RxSwift/events"
		return Future<[MyModel], Error> { [weak self] promise in
			guard let strongSelf = self else { return }
			strongSelf.apiClient.fetchData(urlStr: urlStr)
				.sink { completion in
					if case .failure(let error) = completion {
						promise(.failure(error))
					}
				} receiveValue: {
					promise(.success($0))
					print("view model: \($0.count)")
				}
				.store(in: &strongSelf.cancellables)
		}
	}
}

I wonder if I need to use Future to return data?

Accepted Answer

Okay... The solution to my question is use of the Future guy.

class APIClient: NSObject {
	var cancellables = [AnyCancellable]()
	@Published var models = [MyModel]()
		
	func fetchData(urlStr: String) -> Future<[MyModel], Error> {
		return Future<[MyModel], Error> { [weak self] promise in
			guard let url = URL(string: urlStr) else {
				return promise(.failure("Failure" as! Error))
			}
			guard let strongSelf = self else { return }
			URLSession.shared.dataTaskPublisher(for: url)
			 .map { $0.data }
			 .decode(type: [MyModel].self, decoder: JSONDecoder())
			 .replaceError(with: [])
			 .sink { completion in
				 if case .failure(let error) = completion {
					 promise(.failure(error))
				 }
			 } receiveValue: { promise(.success($0)) }
			 .store(in: &strongSelf.cancellables)
		}
	}
}
class ViewModel: NSObject {
	@IBOutlet var apiClient: APIClient!
	var cancellables = Set<AnyCancellable>()
	@Published var dataModels = [MyModel]()
		
	func getGitData() -> Future<[MyModel], Error> {
		let urlStr = "https://api.github.com/repos/ReactiveX/RxSwift/events"
		return Future<[MyModel], Error> { [weak self] promise in
			guard let strongSelf = self else { return }
			strongSelf.apiClient.fetchData(urlStr: urlStr)
				.sink { completion in
					if case .failure(let error) = completion {
						promise(.failure(error))
					}
				} receiveValue: {
					promise(.success($0))
					print("view model: \($0.count)")
				}
				.store(in: &strongSelf.cancellables)
		}
	}
}

First of all, you should better read this article carefully:

Processing URL Session Data Task Results with Combine

(You may have already read it, in such a case, read it again, carefully.)

With reading the article, I would write something like this:

APIClient

import UIKit
import Combine

class APIClient: NSObject {
    
    func fetchData(urlStr: String) -> AnyPublisher<[MyModel], Never> {
        guard let url = URL(string: urlStr) else {
            let subject = CurrentValueSubject<[MyModel], Never>([])
            return subject.eraseToAnyPublisher()
        }
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: [MyModel].self, decoder: JSONDecoder())
            .replaceError(with: [])
            .eraseToAnyPublisher()
    }
}

ViewModel

import Foundation
import Combine

class ViewModel: NSObject {
    @IBOutlet var apiClient: APIClient!
    var cancellables = Set<AnyCancellable>()
    
    func getGitData() -> AnyPublisher<[MyModel], Never> {
        let urlStr = "https://api.github.com/repos/ReactiveX/RxSwift/events"
        return apiClient.fetchData(urlStr: urlStr)
    }
}

No need to use .sink, just return the modified publisher as AnyPublisher.

(You may want to use Future when you want to define another async method, but this does not seem to be the case.)


Or if you want to use viewModel.dataModels somewhere in the ViewController, I would change the ViewModel as follows:

import Foundation
import Combine

class ViewModel: NSObject {
    @IBOutlet var apiClient: APIClient!
    var cancellables = Set<AnyCancellable>()
    
    private let dataModelSubject = CurrentValueSubject<[MyModel], Never>([])
    var dataModels: [MyModel] {
        get {dataModelSubject.value}
        set {dataModelSubject.send(newValue)}
    }
    func getGitData() -> AnyPublisher<[MyModel], Never> {
        let urlStr = "https://api.github.com/repos/ReactiveX/RxSwift/events"
        apiClient.fetchData(urlStr: urlStr)
            .sink { result in
                print("view model: \(result.count)")
                self.dataModels = result
            }
            .store(in: &cancellables)
        return dataModelSubject.eraseToAnyPublisher()
    }
}

This shows a little different behavior than the previous code, as CurrentValueSubject will send its initial data to subscribers. (0 seems to be the count of the initial value in your code.)


In your code, you hold instances of CurrentValueSubject only as a local constant and never call send to these instance. That does not seem to be the right usage of CurrentValueSubject.

Using URLSession in Combine
 
 
Q