Combine SwiftUI Remote Fetch Data - ObjectBinding doesn't update view - swift

I am trying to learn Combine and it is a PITA for me. I never learned RX Swift, so this is all new to me. I am sure I am missing something simple with this one, but hoping for some help.
I am trying to fetch some JSON from an API and load it in a List view. I have a view model that conforms to ObservableObject and updates a #Published property which is an array. I use that VM to load my list, but it looks like the view loads way before this API returns (List showing up blank). I was hoping these property wrappers would do what I thought they were supposed to and re-render the view whenever the object changed.
Like I said, I am sure I am missing something simple. If you can spot it, I would love the help. Thanks!
class PhotosViewModel: ObservableObject {
var cancellable: AnyCancellable?
#Published var photos = Photos()
func load(user collection: String) {
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.replaceError(with: defaultPhotosObject)
.receive(on: RunLoop.main)
.assign(to: \.photos, on: self)
}
}
struct PhotoListView: View {
#EnvironmentObject var photosViewModel: PhotosViewModel
var body: some View {
NavigationView {
List(photosViewModel.photos) { photo in
NavigationLink(destination: PhotoDetailView(photo)) {
PhotoRow(photo)
}
}.navigationBarTitle("Photos")
}
}
}
struct PhotoRow: View {
var photo: Photo
init(_ photo: Photo) {
self.photo = photo
}
var body: some View {
HStack {
ThumbnailImageLoadingView(photo.coverPhoto.urls.thumb)
VStack(alignment: .leading) {
Text(photo.title)
.font(.headline)
Text(photo.user.firstName)
.font(.body)
}
.padding(.leading, 5)
}
.padding(5)
}
}

Based on your updated solution, here are some improvement suggestions (that wont fit in a comment).
PhotosViewModel improvement suggestions
Might I just make the recommendation of changing your load function from returning Void (i.e. returning nothing), to be returning AnyPublisher<Photos, Never> and skipping the last step .assign(to:on:).
One advantage of this is that your code takes one step toward being testable.
Instead of replaceError with some default value you can use catch together with Empty(completeImmediately: <TRUE/FALSE>). Because is it always possible to come up with any relevant default value? Maybe in this case? Maybe "empty photos"? If so then you can either make Photos conform to ExpressibleByArrayLiteral and use replaceError(with: []) or you can create a static var named empty, allowing for replaceError(with: .empty).
To sum up my suggestions in a code block:
public class PhotosViewModel: ObservableObject {
#Published var photos = Photos()
// var cancellable: AnyCancellable? -> change to Set<AnyCancellable>
private var cancellables = Set<AnyCancellable>()
private let urlSession: URLSession
public init(urlSession: URLSession = .init()) {
self.urlSession = urlSession
}
}
private extension PhotosViewModel {}
func populatePhotoCollection(named nameOfPhotoCollection: String) {
fetchPhotoCollection(named: nameOfPhotoCollection)
.assign(to: \.photos, on: self)
.store(in: &cancellables)
}
func fetchPhotoCollection(named nameOfPhotoCollection: String) -> AnyPublisher<Photos, Never> {
func emptyPublisher(completeImmediately: Bool = true) -> AnyPublisher<Photos, Never> {
Empty<Photos, Never>(completeImmediately: completeImmediately).eraseToAnyPublisher()
}
// This really ought to be moved to some APIClient
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return emptyPublisher()
}
return urlSession.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.catch { error -> AnyPublisher<Photos, Never> in
print("☣️ error decoding: \(error)")
return emptyPublisher()
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
}
*Client suggestion
You might want to write some kind of HTTPClient/APIClient/RESTClient and take a look at HTTP Status codes.
This is a highly modular (and one might argue - overly engineered) solution using a DataFetcher and a DefaultHTTPClient conforming to a HTTPClient protocol:
DataFetcher
public final class DataFetcher {
private let dataFromRequest: (URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError>
public init(dataFromRequest: #escaping (URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError>) {
self.dataFromRequest = dataFromRequest
}
}
public extension DataFetcher {
func fetchData(request: URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError> {
dataFromRequest(request)
}
}
// MARK: Convenience init
public extension DataFetcher {
static func urlResponse(
errorMessageFromDataMapper: ErrorMessageFromDataMapper,
headerInterceptor: (([AnyHashable: Any]) -> Void)?,
badStatusCodeInterceptor: ((UInt) -> Void)?,
_ dataAndUrlResponsePublisher: #escaping (URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError>
) -> DataFetcher {
DataFetcher { request in
dataAndUrlResponsePublisher(request)
.mapError { HTTPError.NetworkingError.urlError($0) }
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse else {
throw HTTPError.NetworkingError.invalidServerResponse(response)
}
headerInterceptor?(httpResponse.allHeaderFields)
guard case 200...299 = httpResponse.statusCode else {
badStatusCodeInterceptor?(UInt(httpResponse.statusCode))
let dataAsErrorMessage = errorMessageFromDataMapper.errorMessage(from: data) ?? "Failed to decode error from data"
print("⚠️ bad status code, error message: <\(dataAsErrorMessage)>, httpResponse: `\(httpResponse.debugDescription)`")
throw HTTPError.NetworkingError.invalidServerStatusCode(httpResponse.statusCode)
}
return data
}
.mapError { castOrKill(instance: $0, toType: HTTPError.NetworkingError.self) }
.eraseToAnyPublisher()
}
}
// MARK: From URLSession
static func usingURLSession(
errorMessageFromDataMapper: ErrorMessageFromDataMapper,
headerInterceptor: (([AnyHashable: Any]) -> Void)?,
badStatusCodeInterceptor: ((UInt) -> Void)?,
urlSession: URLSession = .shared
) -> DataFetcher {
.urlResponse(
errorMessageFromDataMapper: errorMessageFromDataMapper,
headerInterceptor: headerInterceptor,
badStatusCodeInterceptor: badStatusCodeInterceptor
) { urlSession.dataTaskPublisher(for: $0).eraseToAnyPublisher() }
}
}
HTTPClient
public final class DefaultHTTPClient {
public typealias Error = HTTPError
public let baseUrl: URL
private let jsonDecoder: JSONDecoder
private let dataFetcher: DataFetcher
private var cancellables = Set<AnyCancellable>()
public init(
baseURL: URL,
dataFetcher: DataFetcher,
jsonDecoder: JSONDecoder = .init()
) {
self.baseUrl = baseURL
self.dataFetcher = dataFetcher
self.jsonDecoder = jsonDecoder
}
}
// MARK: HTTPClient
public extension DefaultHTTPClient {
func perform(absoluteUrlRequest urlRequest: URLRequest) -> AnyPublisher<Data, HTTPError.NetworkingError> {
return Combine.Deferred {
return Future<Data, HTTPError.NetworkingError> { [weak self] promise in
guard let self = self else {
promise(.failure(.clientWasDeinitialized))
return
}
self.dataFetcher.fetchData(request: urlRequest)
.sink(
receiveCompletion: { completion in
guard case .failure(let error) = completion else { return }
promise(.failure(error))
},
receiveValue: { data in
promise(.success(data))
}
).store(in: &self.cancellables)
}
}.eraseToAnyPublisher()
}
func performRequest(pathRelativeToBase path: String) -> AnyPublisher<Data, HTTPError.NetworkingError> {
let url = URL(string: path, relativeTo: baseUrl)!
let urlRequest = URLRequest(url: url)
return perform(absoluteUrlRequest: urlRequest)
}
func fetch<D>(urlRequest: URLRequest, decodeAs: D.Type) -> AnyPublisher<D, HTTPError> where D: Decodable {
return perform(absoluteUrlRequest: urlRequest)
.mapError { print("☢️ got networking error: \($0)"); return castOrKill(instance: $0, toType: HTTPError.NetworkingError.self) }
.mapError { HTTPError.networkingError($0) }
.decode(type: D.self, decoder: self.jsonDecoder)
.mapError { print("☢️ 🚨 got decoding error: \($0)"); return castOrKill(instance: $0, toType: DecodingError.self) }
.mapError { Error.serializationError(.decodingError($0)) }
.eraseToAnyPublisher()
}
}
Helpers
public protocol ErrorMessageFromDataMapper {
func errorMessage(from data: Data) -> String?
}
public enum HTTPError: Swift.Error {
case failedToCreateRequest(String)
case networkingError(NetworkingError)
case serializationError(SerializationError)
}
public extension HTTPError {
enum NetworkingError: Swift.Error {
case urlError(URLError)
case invalidServerResponse(URLResponse)
case invalidServerStatusCode(Int)
case clientWasDeinitialized
}
enum SerializationError: Swift.Error {
case decodingError(DecodingError)
case inputDataNilOrZeroLength
case stringSerializationFailed(encoding: String.Encoding)
}
}
internal func castOrKill<T>(
instance anyInstance: Any,
toType expectedType: T.Type,
_ file: String = #file,
_ line: Int = #line
) -> T {
guard let instance = anyInstance as? T else {
let incorrectTypeString = String(describing: Mirror(reflecting: anyInstance).subjectType)
fatalError("Expected variable '\(anyInstance)' (type: '\(incorrectTypeString)') to be of type `\(expectedType)`, file: \(file), line:\(line)")
}
return instance
}

This ended up being an issue with my Codable struct not being set up properly. Once I added a default object in the .replaceError method instead of a blank array (Thanks #Asperi), I was able to see the decoding error and fix it. Works like a charm now!
Original:
func load(user collection: String) {
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.replaceError(with: [])
.receive(on: RunLoop.main)
.assign(to: \.photos, on: self)
}
Updated:
func load(user collection: String) {
guard let url = URL(string: "https://api.unsplash.com/users/\(collection)/collections?client_id=\(Keys.unsplashAPIKey)") else {
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Photos.self, decoder: JSONDecoder())
.replaceError(with: defaultPhotosObject)
.receive(on: RunLoop.main)
.assign(to: \.photos, on: self)
}

Related

Result with combine can't get results

I kind of wrote everything correctly and the code itself is working but it gives me an error Result of call to 'fetchPokemon()' is unused, what could be the problem here?
Hear is my code: ModelView class
import Foundation
import Combine
class NetworkManager: ObservableObject {
let baseuRL = "https://pokeapi.co/api/v2/pokemon"
#Published var pokemon: [Pokemon] = []
var error: Error?
var cancellables: Set<AnyCancellable> = []
func fetchPokemon() -> Future<[Pokemon], Error> {
return Future<[Pokemon], Error> { promice in
guard let url = URL(string: "\(self.baseuRL)") else {
return promice(.failure(ApiError.unknowed))
}
URLSession.shared.dataTaskPublisher(for: url)
.tryMap { (data, response) -> Data in
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
throw ApiError.responseError
}
return data
}
.decode(type: PokemonList.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
}, receiveValue: {
promice(.success($0.results))
})
.store(in: &self.cancellables)
}
}
struct ContentView: View {
#StateObject var net = NetworkManager()
var body: some View {
List(net.pokemon, id: \.self) { pokemon in
Text(pokemon.name)
}.onAppear {
net.fetchPokemon()
}
}
}
Your fetchPokemon function returns a Future, but you're not doing anything with it -- that's why you're getting the unused error.
Also, in that function, you're returning your promise, but not doing anything with the results. So, you need to handle the Future and do something with those results.
It might look something like the following:
class NetworkManager: ObservableObject {
let baseuRL = "https://pokeapi.co/api/v2/pokemon"
#Published var pokemon: [Pokemon] = []
var error: Error?
var cancellables: Set<AnyCancellable> = []
//New function here:
func runFetch() {
fetchPokemon().sink { (completion) in
//handle completion, error
} receiveValue: { (pokemon) in
self.pokemon = pokemon //do something with the results from your promise
}.store(in: &cancellables)
}
private func fetchPokemon() -> Future<[Pokemon], Error> {
return Future<[Pokemon], Error> { promice in
guard let url = URL(string: "\(self.baseuRL)") else {
return promice(.failure(ApiError.unknowed))
}
URLSession.shared.dataTaskPublisher(for: url)
.tryMap { (data, response) -> Data in
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
throw ApiError.responseError
}
return data
}
.decode(type: PokemonList.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
}, receiveValue: {
promice(.success($0.results))
})
.store(in: &self.cancellables)
}
}
}
struct ContentView: View {
#StateObject var net = NetworkManager()
var body: some View {
List(net.pokemon, id: \.self) { pokemon in
Text(pokemon.name)
}.onAppear {
net.runFetch() //call runFetch instead of fetchPokemon
}
}
}
Since you didn't include the code for PokemonList I made an assumption about it's content:
struct PokemonList: Codable {
var results: [Pokemon]
}
If the type is different, you'll have to change what happens in receiveValue in runFetch.

Why aren't my Swift network requests working?

I've been trying out Swift/SwiftUI for the first time, so I decided to make a small Hacker News client. I seem to be able to get the list of ids of the top stories back fine, but once the dispatchGroup gets involved, nothing works. What am I doing wrong?
Data.swift
import SwiftUI
struct HNStory: Codable, Identifiable {
var id: UInt
var title: String
var score: UInt
}
class Fetch {
func getStory(id: Int, completion: #escaping (HNStory) -> ()) {
let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")
URLSession.shared.dataTask(with: url!) { (data, _, _) in
let story = try!JSONDecoder().decode(HNStory.self, from: data!)
print(id, story)
DispatchQueue.main.async {
completion(story)
}
}
}
func getStories(completion: #escaping ([HNStory]) -> ()) {
let url = URL(string: "https://hacker-news.firebaseio.com/v0/topstories.json")
var stories: [HNStory] = []
print("here")
URLSession.shared.dataTask(with: url!) { (data, _, _) in
var ids = try!JSONDecoder().decode([Int].self, from: data!)
ids = Array(ids[0...10])
print(ids)
let dispatchGroup = DispatchGroup()
for id in ids {
dispatchGroup.enter()
self.getStory(id: id) { (story) in
stories.append(story)
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) {
print("Completed work")
DispatchQueue.main.async {
completion(stories)
}
}
}.resume()
}
}
ContentView.swift (probably doesn't matter, but just in case)
import SwiftUI
struct ContentView: View {
#State private var stories: [HNStory] = []
var body: some View {
Text("Hacker News").font(.headline)
List(stories) { story in
VStack {
Text(story.title)
Text(story.score.description)
}
}.onAppear{
Fetch().getStories { (stories) in
self.stories = stories
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
One major problem is this line:
Fetch().getStories...
Networking takes time. You make a Fetch instance and immediately let it destroy itself. Thus it does not survive long enough to do any networking! You need to configure a singleton that persists.
Another problem, as OOPer points out in a comment, is that your getStory creates a data task but never tells it to resume — so that method is doing no networking at all, even if it did have time to do so.
FWIW, with Swift UI, I would suggest you consider using Combine publishers for your network requests.
So, the publisher to get a single story:
func storyUrl(for id: Int) -> URL {
URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")!
}
func hackerNewsStoryPublisher(for identifier: Int) -> AnyPublisher<HNStory, Error> {
URLSession.shared.dataTaskPublisher(for: storyUrl(for: identifier))
.map(\.data)
.decode(type: HNStory.self, decoder: decoder)
.eraseToAnyPublisher()
}
And a publisher for a sequence of the above:
func hackerNewsIdsPublisher(for ids: [Int]) -> AnyPublisher<HNStory, Error> {
Publishers.Sequence(sequence: ids.map { hackerNewsStoryPublisher(for: $0) })
.flatMap(maxPublishers: .max(4)) { $0 }
.eraseToAnyPublisher()
}
Note, the above constrains it to four at a time, enjoying the performance gains of concurrency, but limiting it so you do not risk having latter requests time out:
Anyway, here is the first fetch of the array of ids and then launching the above:
let mainUrl = URL(string: "https://hacker-news.firebaseio.com/v0/topstories.json")!
func hackerNewsPublisher() -> AnyPublisher<HNStory, Error> {
URLSession.shared.dataTaskPublisher(for: mainUrl)
.map(\.data)
.decode(type: [Int].self, decoder: decoder)
.flatMap { self.hackerNewsIdsPublisher(for: $0) }
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
(Now, you could probably cram all of the above into a single publisher, but I like to keep them small, so each individual publisher is very easy to reason about.)
So, pulling that all together, you have a view model like so:
import Combine
struct HNStory: Codable, Identifiable {
var id: UInt
var title: String
var score: UInt
}
class ViewModel: ObservableObject {
#Published var stories: [HNStory] = []
private let networkManager = NetworkManager()
private var request: AnyCancellable?
func fetch() {
request = networkManager.hackerNewsPublisher().sink { completion in
if case .failure(let error) = completion {
print(error)
}
} receiveValue: {
self.stories.append($0)
}
}
}
class NetworkManager {
private let decoder = JSONDecoder()
let mainUrl = URL(string: "https://hacker-news.firebaseio.com/v0/topstories.json")!
func storyUrl(for id: Int) -> URL {
URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")!
}
func hackerNewsPublisher() -> AnyPublisher<HNStory, Error> {
URLSession.shared.dataTaskPublisher(for: mainUrl)
.map(\.data)
.decode(type: [Int].self, decoder: decoder)
.flatMap { self.hackerNewsIdsPublisher(for: $0) }
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
// publisher for array of news stories, processing max of 4 at a time
func hackerNewsIdsPublisher(for ids: [Int]) -> AnyPublisher<HNStory, Error> {
Publishers.Sequence(sequence: ids.map { hackerNewsStoryPublisher(for: $0) })
.flatMap(maxPublishers: .max(4)) { $0 }
.eraseToAnyPublisher()
}
// publisher for single news story
func hackerNewsStoryPublisher(for identifier: Int) -> AnyPublisher<HNStory, Error> {
URLSession.shared.dataTaskPublisher(for: storyUrl(for: identifier))
.map(\.data)
.decode(type: HNStory.self, decoder: decoder)
.eraseToAnyPublisher()
}
}
And your main ContentView is:
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
Text("Hacker News").font(.headline)
List(viewModel.stories) { story in
VStack {
Text(story.title)
Text(story.score.description)
}
}.onAppear {
viewModel.fetch()
}
}
}
By calling Fetch().getStories, the Fetch class goes out of scope immediately and isn't retained.
I'd recommend making Fetch an ObservableObject and setting it as a property on your view:
#StateObject var fetcher = Fetch()
Then, call:
fetcher.getStories {
self.stories = stories
}
If you wanted to get even more SwiftUI-ish with it, you may want to look into #Published properties on ObservableObjects and how you can make your view respond to them automatically. By doing this, you could avoid having a #State variable on your view at all, not have to have a callback function, and instead just load the stories into a #Published property on the ObservableObject. Your view will be re-rendered when the #Published property changes. More reading: https://www.hackingwithswift.com/quick-start/swiftui/observable-objects-environment-objects-and-published

Repeat Network Request Multiple Times With Swift Combine

I'm trying to have a combine pipeline that fetches five random photo urls using the Unsplash API, then downloads a photo from each url. The issue I’m running into is that the images are mostly the same. Sometimes (and I don't know why) one of the images will be different than the other four.
Any help would be appreciated. I've included all the necessary code besides the UnSplash Api key.
static func fetchRandomPhotos() -> AnyPublisher<UIImage, Error>{
let string = createURL(path: "/photos/random")
return [1,2,3,4,5]
.publisher
.flatMap{ i -> AnyPublisher<UnsplashImageResults, Error> in
return self.downloadAndDecode(string, type: UnsplashImageResults.self)
}
.flatMap{ result -> AnyPublisher<UIImage, Error> in
print(result.urls.thumb)
let url = URL(string: result.urls.thumb)!
return URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data)! }
.mapError{_ in NetworkError.invalidURL}
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
struct UnsplashURLs: Decodable{
let full: String
let regular: String
let small: String
let thumb: String
}
struct UnsplashImageResults: Decodable{
let urls: UnsplashURLs
}
enum NetworkError: LocalizedError{
case invalidURL
var errorDescription: String? {
switch self {
case .invalidURL:
return "The url is invalid"
}
}
}
static private func createURL(path: String)-> String{
var components = URLComponents()
components.scheme = "https"
components.host = "api.unsplash.com"
components.path = path
components.queryItems = [
URLQueryItem(name: "client_id", value: "YOUR API KEY HERE")
]
return components.string!
}
static private func downloadAndDecode<T:Decodable>(_ urlString: String, type: T.Type) -> AnyPublisher<T, Error>{
guard let url = URL(string: urlString) else{
return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap() { element -> Data in
guard let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return element.data
}
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
class UnsplashImagesViewModel: ObservableObject{
var subscriptions = Set<AnyCancellable>()
#Published var images = [UIImage]()
init(){
UnsplashAPI.fetchRandomPhotos()
.sink { (_) in
} receiveValue: { image in
self.images.append(image)
}.store(in: &subscriptions)
}
}
struct UnsplashImagesGrid: View {
#StateObject private var model = UnsplashImagesViewModel()
var body: some View {
List(model.images, id: \.self){ image in
Image(uiImage: image)
}
}
}
Unsplash returns the same image, if you send multiple requests at the same time. You can test this behaviour by introducing a delay between your requests.
Anyway, you should be using the count param of Unsplash random API to request a particular number of images. I have made some changes to your code, to receive an array of UIImage. I hope this gives you an idea about, how it can improved further.
static func fetchRandomPhotos() -> AnyPublisher<[UIImage], Error>{
let string = createURL(path: "/photos/random")
return Just(())
.print()
.flatMap{ i -> AnyPublisher<[UnsplashImageResults], Error> in
return self.downloadAndDecode(string, type: UnsplashImageResults.self)
}
.flatMap{ results -> AnyPublisher<[UIImage], Error> in
let images = results.map { result -> AnyPublisher<UIImage, Error> in
let url = URL(string: result.urls.thumb)!
return URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data)! }
.mapError{_ in NetworkError.invalidURL}
.eraseToAnyPublisher()
}
return Publishers.MergeMany(images)
.collect()
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
static private func createURL(path: String)-> String{
...
components.queryItems = [
URLQueryItem(name: "client_id", value: "ID"),
URLQueryItem(name: "count", value: "5")
]
...
}
static private func downloadAndDecode<T:Decodable>(_ urlString: String, type: T.Type) -> AnyPublisher<[T], Error>{
...
.decode(type: [T].self, decoder: JSONDecoder())
...
}

How to mock DataTaskPublisher?

I'm trying to write some unit tests for my API using URLSession.DataTaskPublisher. I've found an already existing question on Stackoverflow for the same but I'm struggling to implement a working class using the proposed solution.
Here's the existing question: How to mock URLSession.DataTaskPublisher
protocol APIDataTaskPublisher {
func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher
}
class APISessionDataTaskPublisher: APIDataTaskPublisher {
func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
return session.dataTaskPublisher(for: request)
}
var session: URLSession
init(session: URLSession = URLSession.shared) {
self.session = session
}
}
class URLSessionMock: APIDataTaskPublisher {
func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
// How can I return a mocked URLSession.DataTaskPublisher here?
}
}
My API then uses the above like this:
class MyAPI {
/// Shared URL session
private let urlSession: APIDataTaskPublisher
init(urlSession: APIDataTaskPublisher = APISessionDataTaskPublisher(session: URLSession.shared)) {
self.urlSession = urlSession
}
}
What I don't know is how to implement URLSessionMock.dataTaskPublisher().
It would probably be simpler not to mock DataTaskPublisher. Do you really care if the publisher is a DataTaskPublisher? Probably not. What you probably care about is getting the same Output and Failure types as DataTaskPublisher. So change your API to only specify that:
protocol APIProvider {
typealias APIResponse = URLSession.DataTaskPublisher.Output
func apiResponse(for request: URLRequest) -> AnyPublisher<APIResponse, URLError>
}
Conform URLSession to it for production use:
extension URLSession: APIProvider {
func apiResponse(for request: URLRequest) -> AnyPublisher<APIResponse, URLError> {
return dataTaskPublisher(for: request).eraseToAnyPublisher()
}
}
And then your mock can create the publisher in any way that's convenient. For example:
struct MockAPIProvider: APIProvider {
func apiResponse(for request: URLRequest) -> AnyPublisher<APIResponse, URLError> {
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
let data = "Hello, world!".data(using: .utf8)!
return Just((data: data, response: response))
.setFailureType(to: URLError.self)
.eraseToAnyPublisher()
}
}
If you store in UT bundle stub JSON (XML, or something) for every API call that you want to test then the simplest mocking code might look as following
class URLSessionMock: APIDataTaskPublisher {
func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
// here might be created a map of API URLs to cached stub replies
let stubReply = request.url?.lastPathComponent ?? "stub_error"
return URLSession.shared.dataTaskPublisher(for: Bundle(for: type(of: self)).url(forResource: stubReply, withExtension: "json")!)
}
}
so instead call to network server your publisher is created with URL of locally stored resource with known data, so you can verify all your workflow.
I will develop the step from having a simple Get request, to mocking .dataTaskPublisher for Combine and for the last part, testing the call. It is a ready to use code, for everyone in case someone else would need it.
Follow the comment to add your model or anything that depends on your project data.
So this is the protocol that give the rules to my NetworkRequest class:
protocol NetworkRequestProtocol {
associatedtype Resource
var resourceURL: NetworkEndpoint { get set }
var resourceSession: URLSession { get set }
func download() -> AnyPublisher<Resource, NetworkError>
}
There is some custom class, NetworkEndpoint and NetworkError, you can add your own here if you want or use URL and URLError instead:
enum NetworkEndpoint {
static let baseURL = URL(string: "API_BASE_URL")! // Add your api base url here
case live
var url: URL {
switch self {
case .live:
return NetworkEndpoint.baseURL!.appendingPathComponent("END_OR_YOUR_API_URL") // Add the end of your API url here
}
}
}
enum NetworkError: LocalizedError {
case addressUnreachable(URL)
case invalidResponse
var errorDescription: String? {
switch self {
case .invalidResponse:
return "The server response is invalid."
case .addressUnreachable(let url):
return "\(url.absoluteString) is unreachable."
}
}
}
Now, I am creating the NetworkRequest class to handle the API call. RessourceSession initializer is used for the UnitTest part only:
final class NetworkRequest<Resource> where Resource: Codable {
var resourceURL: NetworkEndpoint
var resourceSession: URLSession
init(_ resourceURL: NetworkEndpoint,
resourceSession: URLSession = URLSession(configuration: .default)) {
self.resourceURL = resourceURL
self.resourceSession = resourceSession
}
// MARK: - Dispatch Queues
let downloadQueue = DispatchQueue(
label: "downloadQueue", qos: .userInitiated,
attributes: .concurrent, autoreleaseFrequency: .inherit, target: .main)
}
// MARK: - Network Requests
extension NetworkRequest: NetworkRequestProtocol {
func download() -> AnyPublisher<Resource, NetworkError> {
resourceSession
.dataTaskPublisher(for: resourceURL.url)
.receive(on: downloadQueue)
.map(\.data)
.decode(type: Resource.self, decoder: JSONDecoder())
.mapError { error -> NetworkError in
switch error {
case is URLError:
return .addressUnreachable(self.resourceURL.url)
default:
return .invalidResponse }}
.eraseToAnyPublisher()
}
}
For the production code, this is an example of use of the NetworkRequest class, and of course, your model must be Codable:
var subscriptions = Set<AnyCancellable>()
func downloadData() {
NetworkRequest<YOUR_MODEL_NAME>(.live).download() // Add your model name inside the brackets
.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
print(error)
case .finished:
break }},
receiveValue: { data in
print(data) })
.store(in: &subscriptions)
}
So now that all the code is setup in the project, we can pass to the UnitTest part and start mocking URLSession:
class MockURLSession: URLSession {
var data: Data?
var response: URLResponse?
var error: Error?
init(data: Data?, response: URLResponse?, error: Error?) {
self.data = data
self.response = response
self.error = error
}
override func dataTask(with request: URLRequest,
completionHandler: #escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
let data = self.data
let response = self.response
let error = self.error
return MockURLSessionDataTask {
completionHandler(data, response, error)
}
}
}
Now, we mock URLSessionDataTask that we return when overriding dataTask in MockURLSession, and it will work for .dataTaskPublisher:
class MockURLSessionDataTask: URLSessionDataTask {
private let closure: () -> Void
init(closure: #escaping () -> Void) {
self.closure = closure
}
override func resume() {
closure()
}
}
We create fake response data to pass into our tests, but you must create a .json file with your data in it to fetch them in the tests:
class FakeResponseData {
static let response200OK = HTTPURLResponse(url: URL(string: "https://test.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!
static let responseKO = HTTPURLResponse(url: URL(string: "https://test.com")!,
statusCode: 500,
httpVersion: nil,
headerFields: nil)!
class RessourceError: Error {}
static let error = RessourceError()
static var correctData: Data {
let bundle = Bundle(for: FakeResponseData.self)
let fakeJsonURL = bundle.url(forResource: "FAKE_JSON_FILE_NAME", withExtension: "json") // Add your fake json file name in here
let fakeJsonData = try! Data(contentsOf: fakeJsonURL!)
return fakeJsonData
}
static let incorrectData = "error".data(using: .utf8)!
}
And to finish, this is the part where you test your NetworkRequest, with the fake data coming from the .json file, or the error. You use resourceSession initializer to add your MockURLSession here and avoid making real network call:
class NetworkRequestTests: XCTestCase {
var expectation: XCTestExpectation!
var subscriptions: Set<AnyCancellable>!
override func setUpWithError() throws {
try super.setUpWithError()
expectation = XCTestExpectation(description: "wait for queue change")
subscriptions = Set<AnyCancellable>()
}
override func tearDownWithError() throws {
subscriptions = nil
expectation = nil
try super.tearDownWithError()
}
func testNetworkRequest_mockURLSessionAddCorrectDataResponse_returnRatesDataModelValues() throws {
let expectedTestValue = "test" // This value is set in your .json fake data for testing
// This is where you use resourceSession to pass your fake data
let networkRequest = NetworkRequest<RatesData>(.live, resourceSession:
MockURLSession(data: FakeResponseData.correctData,
response: FakeResponseData.response200OK,
error: nil))
networkRequest.download()
.sink(
receiveCompletion: { completion in
self.expectation.fulfill() },
receiveValue: { value in
XCTAssertEqual(expectedTimestamp, value.InFakeJson) // Compare with your fake json file
})
.store(in: &subscriptions)
wait(for: [expectation], timeout: 0.1)
}
func testNetworkRequest_mockURLSessionAddServerErrorAsResponse_returnNetworkErrorInvalidResponse() throws {
let expectedNetworkError = NetworkError.invalidResponse.localizedDescription
// This is where you use resourceSession to pass your fake data
let networkRequest = NetworkRequest<RatesData>(.live, resourceSession:
MockURLSession(data: nil,
response: FakeResponseData.responseKO,
error: nil))
networkRequest.download()
.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
XCTAssertEqual(expectedNetworkError, error.localizedDescription)
case .finished:
break
}
self.expectation.fulfill() },
receiveValue: { value in
XCTAssertNil(value)
})
.store(in: &subscriptions)
wait(for: [expectation], timeout: 0.1)
}
}
Answered on original question, but will repost here:
Since DataTaskPublisher uses the URLSession it is created from, you can just mock that. I ended up creating a URLSession subclass, overriding dataTask(...) to return a URLSessionDataTask subclass, which I fed with the data/response/error I needed...
class URLSessionDataTaskMock: URLSessionDataTask {
private let closure: () -> Void
init(closure: #escaping () -> Void) {
self.closure = closure
}
override func resume() {
closure()
}
}
class URLSessionMock: URLSession {
var data: Data?
var response: URLResponse?
var error: Error?
override func dataTask(with request: URLRequest, completionHandler: #escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
let data = self.data
let response = self.response
let error = self.error
return URLSessionDataTaskMock {
completionHandler(data, response, error)
}
}
}
Then obviously you just want your networking layer using this URLSession, I went with a factory to do this:
protocol DataTaskPublisherFactory {
func make(for request: URLRequest) -> URLSession.DataTaskPublisher
}
Then in your network layer:
func performRequest<ResponseType>(_ request: URLRequest) -> AnyPublisher<ResponseType, APIError> where ResponseType : Decodable {
Just(request)
.flatMap {
self.dataTaskPublisherFactory.make(for: $0)
.mapError { APIError.urlError($0)} } }
.eraseToAnyPublisher()
}
Now you can just pass a mock factory in the test using the URLSession subclass (this one asserts URLErrors are mapped to a custom error, but you could also assert some other condition given data/response):
func test_performRequest_URLSessionDataTaskThrowsError_throwsAPIError() {
let session = URLSessionMock()
session.error = TestError.test
let dataTaskPublisherFactory = mock(DataTaskPublisherFactory.self)
given(dataTaskPublisherFactory.make(for: any())) ~> {
session.dataTaskPublisher(for: $0)
}
let api = API(dataTaskPublisherFactory: dataTaskPublisherFactory)
let publisher: AnyPublisher<TestCodable, APIError> =
api.performRequest(URLRequest(url: URL(string: "www.someURL.com")!))
let _ = publisher.sink(receiveCompletion: {
switch $0 {
case .failure(let error):
XCTAssertEqual(error, APIError.urlError(URLError(_nsError: NSError(domain: "NSURLErrorDomain", code: -1, userInfo: nil))))
case .finished:
XCTFail()
}
}) { _ in }
}
The one issue with this is that URLSession init() is deprecated from iOS 13, so you have to live with a warning in your test. If anyone can see a way around that I'd greatly appreciate it.
(Note: I'm using Mockingbird for mocks).

Swift Combine. How to transform publisher values

I need to download files using provided links from the backend. To download files the asynchronous API is used that returns Progress() object. The problem is that FlatMap cannot map from Publisher<[Link], Error> to Publisher<[File], Error>. Another problem I want to solve is getting rid of cancellable and somehow transform Progress info filePath once the Progress.fractionCompleted is equal to 1.0.
For now I have tried to use map function. See the code form the Playground:
import UIKit
import Combine
var progress = Progress()
extension ProgressUserInfoKey {
public static var destinationURL = ProgressUserInfoKey("destinationURL")
}
func download(from urlRequest: URLRequest, to destinationURL: URL) -> AnyPublisher<Progress, Error> {
return Future<Progress, Error> { promise in
progress = Progress(totalUnitCount: 1)
progress.setUserInfoObject(destinationURL.absoluteString,
forKey: ProgressUserInfoKey.destinationURL)
promise(.success(progress))
// Simulate async API
DispatchQueue.main.async {
progress.completedUnitCount = 1
}
}.eraseToAnyPublisher()
}
struct Link: Decodable {
let url: String
}
func getLinks() -> AnyPublisher<[Link], Error> {
return URLSession.shared.dataTaskPublisher(for: URL(string: "https://backend.com")!)
.map { $0.data }
.decode(type: [Link].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
struct File {
let url: URL
let size: UInt32
}
private func destinationUrl(_ fromUrl: String?) -> URL {
guard let path = fromUrl else {
return URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
}
return URL(fileURLWithPath: path)
}
/// 2) How to get rid of this state and transoft Progress into filePath directly (using matp(transform: ? )
var cancellableSet = Set<AnyCancellable>()
func getFiles() -> AnyPublisher<[File], Error> {
getLinks()
.flatMap { (links) -> AnyPublisher<[File], Error> in
let sequence = Sequence<[AnyPublisher<File, Error>], Error>(sequence: links.map {
download(from: URLRequest(url: $0), to: destinationUrl(UUID().uuidString))
.sink { progress in
progress.publisher(for: \.fractionCompleted).sink { progressValue in
if progressValue == 1.0 {
let filePath: String = progress.userInfo[ProgressUserInfoKey.destinationURL] as? String ?? ""
/// 1) How to return publisher here
///return Publisher(File(url: URL(string: filePath)!, size: 0))
}
}
.store(in: &cancellableSet)
}
.store(in: &cancellableSet)
} )
return sequence.flatMap { $0 }.collect().eraseToAnyPublisher()
}
}
I am expecting that the code compiles successfully and the function getFiles returns AnyPublisher<[File], Error>.
Currently the error code am getting is the following:
Cannot convert return expression of type 'Publishers.FlatMap<AnyPublisher<[File], Error>, AnyPublisher<[Link], Error>>' to return type 'AnyPublisher<[File], Error>'
Problem has been solved by using Publishers.Sequence() extension and avoiding to call a function sink(). Also I used the flatMap() to transform the Output value. The code that demonstrate this way might be found here: Combine publishers together