Home > Net >  FlatMap with Generic ReturnType using Combine
FlatMap with Generic ReturnType using Combine

Time:10-31

I'm building a network API. I'm new to Combine and I'm having some troubles with it, I'm trying to chain publish network requests, in this case I'm forming an URLRequest publisher and dispatching it on another publisher, the problem is that I cant make the flatMap work on the second publisher.

First I assemble the URLRequest with the Auth token:

func asURLRequest(baseURL: String) -> AnyPublisher<URLRequest, NetworkRequestError> {
        
        return Deferred {
            Future<URLRequest, NetworkRequestError> { promise in
                if var urlComponents = URLComponents(string: baseURL) {
                    urlComponents.path = "\(urlComponents.path)\(path)"
                    urlComponents.queryItems = queryItemsFrom(params: queryParams)
                    if let finalURL = urlComponents.url {
                        if let user = Auth.auth().currentUser {
                            print("##### final url -> \(finalURL)")
                            // Retrieves the Firebase authentication token, possibly refreshing it if it has expired.
                            user.getIDToken(completion: { (token, error) in
                                if let fbToken = token {
                                    var request = URLRequest(url: finalURL)
                                    request.httpMethod = method.rawValue
                                    request.httpBody = requestBodyFrom(params: body)
                                    let defaultHeaders: HTTPHeaders = [
                                        HTTPHeaderField.contentType.rawValue: contentType.rawValue,
                                        HTTPHeaderField.acceptType.rawValue: contentType.rawValue,
                                        HTTPHeaderField.authentication.rawValue: fbToken
                                    ]
                                    request.allHTTPHeaderFields = defaultHeaders.merging(headers ?? [:], uniquingKeysWith: { (first, _) in first })
                                    print("##### API TOKEN() SUCCESS: \(defaultHeaders)")
                                    promise(.success(request))
                                }
                                
                                if let fbError = error {
                                    print("##### API TOKEN() ERROR: \(fbError)")
                                    promise(.failure(NetworkRequestError.decodingError))
                                }
                            })
                        }
                    } else {
                        promise(.failure(NetworkRequestError.decodingError))
                    }
                } else {
                    promise(.failure(NetworkRequestError.decodingError))
                }
            }
        }.eraseToAnyPublisher()
    }

Then I'm trying to dispatch a request (publisher) and return another publisher, the problem is that the .flatMap is not getting called:

struct APIClient {
    var baseURL: String!
    var networkDispatcher: NetworkDispatcher!
    init(baseURL: String,
         networkDispatcher: NetworkDispatcher = NetworkDispatcher()) {
        self.baseURL = baseURL
        self.networkDispatcher = networkDispatcher
    }
    /// Dispatches a Request and returns a publisher
    /// - Parameter request: Request to Dispatch
    /// - Returns: A publisher containing decoded data or an error
    func dispatch<R: Request>(_ request: R) -> AnyPublisher<R.ReturnType, NetworkRequestError> {
        print("##### --------> \(request)")
        //typealias RequestPublisher = AnyPublisher<R.ReturnType, NetworkRequestError>
        return request.asURLRequest(baseURL: baseURL)
            .flatMap { request in
                //NOT GETTING CALLED
                self.networkDispatcher.dispatch(request: request)
            }.eraseToAnyPublisher()

}

The final publisher that is not being called is the following:

struct NetworkDispatcher {
    let urlSession: URLSession!
    public init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }
    /// Dispatches an URLRequest and returns a publisher
    /// - Parameter request: URLRequest
    /// - Returns: A publisher with the provided decoded data or an error
    func dispatch<ReturnType: Codable>(request: URLRequest) -> AnyPublisher<ReturnType, NetworkRequestError> {
        return urlSession
            .dataTaskPublisher(for: request)
        // Map on Request response
            .tryMap({ data, response in
                // If the response is invalid, throw an error
                if let response = response as? HTTPURLResponse,
                   !(200...299).contains(response.statusCode) {
                    throw httpError(response.statusCode)
                }
                // Return Response data
                return data
            })
        // Decode data using our ReturnType
            .decode(type: ReturnType.self, decoder: JSONDecoder())
        // Handle any decoding errors
            .mapError { error in
                handleError(error)
            }
        // And finally, expose our publisher
            .eraseToAnyPublisher()
    }
}

Running the code:

 struct ReadUser: Request {
        typealias ReturnType = UserData
        var path: String
        var method: HTTPMethod = .get
        init(_ id: String) {
            path = "users/\(id)"
        }
    }
    
    let apiClient = APIClient(baseURL: BASE_URL)
    var cancellables = [AnyCancellable]()
    
    apiClient.dispatch(ReadUser(Auth.auth().currentUser?.uid ?? ""))
        .receive(on: DispatchQueue.main)
        .sink(
            receiveCompletion: { result in
                switch result {
                case .failure(let error):
                    // Handle API response errors here (WKNetworkRequestError)
                    print("##### Error loading data: \(error)")
                default: break
                }
            },
            receiveValue: { value in
            })
        .store(in: &cancellables)

CodePudding user response:

I took your code and boiled it down to just the Combine parts. I could not reproduce the issue you are describing. I'll post that code below. I recommend you start simplifying your code a bit at a time to see if that helps. Factoring out the Auth and Facebook token code seems like a good candidate to start with. Another good debugging technique might be to put in more explicit type declarations to make sure your closures are taking and returning what you expect. (just the other day I had a map that I thought I was applying to an Array when I was really mapping over Optional).

Here's the playground:

import UIKit import Combine

func asURLRequest(baseURL: String) -> AnyPublisher<URLRequest, Error> {
    return Deferred {
        Future<URLRequest, Error> { promise in
            promise(.success(URLRequest(url: URL(string: "https://www.apple.com")!)))
        }
    }.eraseToAnyPublisher()
}

struct APIClient {
    var networkDispatcher: NetworkDispatcher!
    init(networkDispatcher: NetworkDispatcher = NetworkDispatcher()) {
        self.networkDispatcher = networkDispatcher
    }
    
    func dispatch() -> AnyPublisher<Data, Error> {
        return asURLRequest(baseURL: "Boo!")
            .flatMap { (request: URLRequest) -> AnyPublisher<Data, Error> in
                print("Request Received. \(String(describing: request))")
                return self.networkDispatcher.dispatch(request: request)
            }.eraseToAnyPublisher()
    }
}

func httpError(_ code: Int) -> Error {
    return NSError(domain: "Bad Things", code: -1, userInfo: nil)
}

func handleError(_ error: Error) -> Error {
    debugPrint(error)
    return error
}

struct NetworkDispatcher {
    let urlSession: URLSession!
    
    public init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }
    
    func dispatch(request: URLRequest) -> AnyPublisher<Data, Error> {
        return urlSession
            .dataTaskPublisher(for: request)
            .tryMap({ data, response in
                if let response = response as? HTTPURLResponse,
                   !(200...299).contains(response.statusCode) {
                    throw httpError(response.statusCode)
                }
                
                // Return Response data
                return data
            })
            .mapError { error in
                handleError(error)
            }
            .eraseToAnyPublisher()
    }
}

let apiClient = APIClient()
var cancellables = [AnyCancellable]()

apiClient.dispatch()
    .print()
    .receive(on: DispatchQueue.main)
    .sink(
        receiveCompletion: { result in
            debugPrint(result)
            
            switch result {
                case .failure(let error):
                    // Handle API response errors here (WKNetworkRequestError)
                    print("##### Error loading data: \(error)")
                default: break
            }
        },
        receiveValue: { value in
            debugPrint(value)
        })
    .store(in: &cancellables)

CodePudding user response:

I refactored your code. Breaking down the offending method into several functions. I could not find any problem. Below is my refactoring. You will notice that I broke all the code that constructs things into their own functions so they can be easily tested without dealing with the effect (I don't even have to mock the effect to test the logic.)

extension Request {
    func asURLRequest(baseURL: String) -> AnyPublisher<URLRequest, NetworkRequestError> {
        guard let user = Auth.auth().currentUser else {
            return Fail(error: NetworkRequestError.missingUser)
                .eraseToAnyPublisher()
        }
        return user.idTokenPublisher()
            .catch { error in
                Fail(error: NetworkRequestError.badToken(error))
            }
            .tryMap { token in
                makeRequest(
                    finalURL: try finalURL(baseURL: baseURL),
                    fbToken: token
                )
            }
            .eraseToAnyPublisher()
    }
    
    func finalURL(baseURL: String) throws -> URL {
        guard var urlComponents = URLComponents(string: baseURL) else {
            throw NetworkRequestError.malformedURLComponents
        }
        urlComponents.path = "\(urlComponents.path)\(path)"
        urlComponents.queryItems = queryItemsFrom(params: queryParams)
        guard let result = urlComponents.url else {
            throw NetworkRequestError.malformedURLComponents
        }
        return result
    }

    func makeRequest(finalURL: URL, fbToken: String) -> URLRequest {
        var request = URLRequest(url: finalURL)
        request.httpMethod = method.rawValue
        request.httpBody = requestBodyFrom(params: body)
        let defaultHeaders: HTTPHeaders = [
            HTTPHeaderField.contentType.rawValue: contentType.rawValue,
            HTTPHeaderField.acceptType.rawValue: contentType.rawValue,
            HTTPHeaderField.authentication.rawValue: fbToken
        ]
        request.allHTTPHeaderFields = defaultHeaders.merging(
            headers ?? [:],
            uniquingKeysWith: { (first, _) in first }
        )
        return request
    }
}

extension User {
    func idTokenPublisher() -> AnyPublisher<String, Error> {
        Deferred {
            Future { promise in
                getIDToken(completion: { token, error in
                    if let token = token {
                        promise(.success(token))
                    }
                    else {
                        promise(.failure(error ?? UnknownError()))
                    }
                })
            }
        }
        .eraseToAnyPublisher()
    }
}

struct UnknownError: Error { }
  • Related