I'm working on caching for my network module. My module will return an AnyCancallable back to the caller for each request. If cached data is not available, I use URLSession.dataTaskPublisher, it works fine with 2 events: data received and completion. If cached data is available, I will use a CurrentValueSubject to create the AnyCancallable to return. I send both of the 2 events on this subject, but on the caller side, it only gets notified on the completion, no data.
cacheSubject.send(cachedData.data)
cacheSubject.send(completion: Subscribers.Completion<Error>.finished)
Removing the completion send and now it can receive data, but no completion notification.
Could someone please let me know what I'm doing wrong here?
Here is the full file in case you guys need it:
public class SSNetworkManager {
public static let shared = SSNetworkManager()
private var cache: [String: CachedData] = [:]
private let cacheSubject = CurrentValueSubject<Data, Error>(Data())
#discardableResult public func makeServiceCall<D: Decodable>(forRequest request: SSNetworkRequest<D>, onMainThread: Bool = true) -> AnyPublisher<D, Error>? {
guard let urlRequest = request.urlRequest else {
return nil
}
var cancelable: AnyPublisher<Data, Error>
if let url = urlRequest.url?.absoluteString,
let cachedData = cache[url],
cachedData.isValid {
cancelable = cacheSubject.eraseToAnyPublisher()
cacheSubject.send(cachedData.data)
cacheSubject.send(completion: Subscribers.Completion<Error>.finished)
} else {
cancelable = URLSession.shared.dataTaskPublisher(for: urlRequest).tryMap {[weak self] (data, response) -> Data in
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw SSNetworkError(httpCode: (response as? HTTPURLResponse)?.statusCode ?? 0, data: data)
}
if request.shouldCacheNow,
let url = urlRequest.url?.absoluteString {
self?.cache[url] = CachedData(data: data, expirationTime: request.cacheExpirationTime)
}
return data
}.eraseToAnyPublisher()
}
if onMainThread {
return cancelable
.receive(on: RunLoop.main)
.decode(type: D.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
} else {
return cancelable
.decode(type: D.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
}
fileprivate struct CachedData {
let data: Data
let expirationTime: Date
var isValid: Bool {
return Date().compare(expirationTime) != .orderedDescending
}
}
This isn't the right case to use a subject. Instead, return the publisher, relevant to each case:
public class SSNetworkManager {
// ...
public func makeServiceCall<D: Decodable>(
forRequest request: SSNetworkRequest<D>,
onMainThread: Bool = true
) -> AnyPublisher<D, Error>? {
// consider just returning Empty().eraseToAnyPublisher() instead of nil
guard let urlRequest = request.urlRequest else {
return nil
}
var resultPublisher: AnyPublisher<D: Error>
if let url = urlRequest.url?.absoluteString,
let cachedData = cache[url],
cachedData.isValid {
resultPublisher = Just(cachedData.data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
} else {
resultPublisher = URLSession.shared
.dataTaskPublisher(for: urlRequest)
.tryMap { ... }
.decode(type: D.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
return onMainThread
? resultPublisher
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
: resultPublisher
.eraseToAnyPublisher()
}
}
Related
I am implementing a network layer using combine and having a hard time extracting the inner array as the publisher in the type I need in a single swipe. I have it working but as 2 separate functions where on the second within .sink I pull what I need but I want it all to happen within the 1st function... current state for reference below.
json returned from api for reference:
{
"count": 1,
"entities": [
{facetids:[],
identifiers:[],
description:String},
{facetids:[],
identifiers:[],
description:String}
]
}
What Entities looks like:
struct Entities:Decodable
{
var count:Int?
var entities: [Entity]?
}
NetworkController.swift:
func get<T>(type: T.Type, url: URL, headers: Headers) -> AnyPublisher<T, Error> where T : Decodable
{
var urlRequest = URLRequest(url: url)
headers.forEach
{
key, value in
if let value = value as? String
{
urlRequest.setValue(value, forHTTPHeaderField: key)
}
}
return URLSession.shared.dataTaskPublisher(for: urlRequest)
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.map(\.data)
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
EntitiesLogicController.swift:
func getEntities(named: String, count: Int) -> AnyPublisher<Entities, Error> {
let endpoint = Endpoint.companies(named: named, count: count)
return networkController.get(type: Entities.self, url: endpoint.url, headers: endpoint.headers)
}
ApiCaller.swift:
func getEntities(named: String)
{
entitiesLogicController?.getEntities(named: named, count: 20).sink{
[weak self] completion in
if case let .failure(error) = completion
{
print("Error retrieving Entity, ERROR: \(error)")
} else if case .finished = completion
{
print("Data successfully downloaded")
}
} receiveValue:
{
requestedEntity in
do{
let entities:[Entity] = try requestedEntity.entities!.compactMap{
entity in
do
{
return try Entity(from: entity)
}
}
self.entities = entities
}catch
{
print("Error")
}
}.store(in: &subscriptions)
}
I want/need the get to return AnyPublisher<[T], Error> instead
where [T] == [Entity] decodable structs work perfectly fine and everything else as well.
If you want the get to return AnyPublisher<[T], Error> instead where [T] == [Entity] then you should have a generic LogicController which will handle a generic call instead of EntitiesLogicController.swift
GenericLogicController.swift
func getObjects<T>(parameters: [String: Any]) -> AnyPublisher<T, Error> where T : Decodable {
let endpoint = //create a genric endpoint - or use switch to differentiate
return networkController.get(type: T.self, url: endpoint.url, headers: endpoint.headers)
}
I am trying to figure out why the last .eraseToAnyPublisher() is giving the aforementioned error, to me it seems all the types are well specified, aren't they?
static func searchUsers(query: String) -> AnyPublisher<[UserViewModel], Never> {
// prepare URL
let urlString = "\(baseURL)/search/users?q=\(query)"
guard let url = URL(string: urlString) else {
return Just([]).eraseToAnyPublisher()
}
// get handle of native data task publisher
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.handleEvents(
receiveSubscription: { _ in
activityIndicatorPublisher.send(true)
}, receiveCompletion: { _ in
activityIndicatorPublisher.send(false)
}, receiveCancel: {
activityIndicatorPublisher.send(false)
})
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.httpError
}
print(String(data: data, encoding: .utf8) ?? "")
return data
}
.decode(type: SearchUserResponse.self, decoder: JSONDecoder())
.map { $0.items }
.flatMap({ users in
var userViewModels = [UserViewModel]()
users.forEach { user in
userViewModels.append(contentsOf: UserViewModel(with: user))
}
return userViewModels
})
.catch { err -> Just<[UserViewModel]> in
print(err)
return Just([])
}
.eraseToAnyPublisher() // <-- HERE IS THE ERROR
return publisher
}
Unfortunately with those complex Combine pipelines sometimes compliler errors are displayed on the wrong line. In your case there are two problems, but not where the compiler is pointing.
One being use of flatMap instead of map.
This part of the pipeline:
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.handleEvents(
receiveSubscription: { _ in
activityIndicatorPublisher.send(true)
}, receiveCompletion: { _ in
activityIndicatorPublisher.send(false)
}, receiveCancel: {
activityIndicatorPublisher.send(false)
})
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.httpError
}
print(String(data: data, encoding: .utf8) ?? "")
return data
}
.decode(type: SearchUserResponse.self, decoder: JSONDecoder())
.map { $0.items }
returns Publisher<[User], Error>.
Next you want to transforming that into Publisher<[UserViewModel], Error> for which you need a function map:
func map<T>(_ transform: #escaping (Output) -> T) -> Publishers.Map<Upstream, T>
which transforms one type of Output into another type of Output not flatMap:
func flatMap<T, P>(maxPublishers: Subscribers.Demand = .unlimited, _ transform: #escaping (Self.Output) -> P) -> Publishers.FlatMap<P, Self> where T == P.Output, P : Publisher, Self.Failure == P.Failure
which transforms Output into a new Publisher
The second problem is with append(contentsOf;) which expects a Sequence of elements, in case of single elements you should use append(), but even simpler would be just to map the [User] to [UserViewModel]:
{ users in
users.map { user in
UserViewModel(with: user)
}
}
so the whole function should work with those changes:
static func searchUsers(query: String) -> AnyPublisher<[UserViewModel], Never> {
// prepare URL
let urlString = "\(baseURL)/search/users?q=\(query)"
guard let url = URL(string: urlString) else {
return Just([]).eraseToAnyPublisher()
}
// get handle of native data task publisher
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.handleEvents(
receiveSubscription: { _ in
activityIndicatorPublisher.send(true)
}, receiveCompletion: { _ in
activityIndicatorPublisher.send(false)
}, receiveCancel: {
activityIndicatorPublisher.send(false)
})
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.httpError
}
print(String(data: data, encoding: .utf8) ?? "")
return data
}
.decode(type: SearchUserResponse.self, decoder: JSONDecoder())
.map { $0.items }
.map { users in
users.map { user in
UserViewModel(with: user)
}
}
.catch { err -> Just<[UserViewModel]> in
print(err)
return Just([])
}
.eraseToAnyPublisher()
return publisher
}
// get handle of native data task publisher
let publisher = URLSession.shared.dataTaskPublisher(for: URL)
.handleEvents(
receiveSubscription: { _ in
activityIndicatorPublisher.send(true)
}, receiveCompletion: { _ in
activityIndicatorPublisher.send(false)
}, receiveCancel: {
activityIndicatorPublisher.send(false)
})
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.httpError
}
return data
}
.decode(type: Repository.self, decoder: JSONDecoder())
.map { $0 }
.catch { err in
return Just([])
}
.eraseToAnyPublisher()
return publisher
I am new to Combine and I canĀ“t figure out what should I put inside the .map{} closure in order to return an array of Repository objects. The error I get at compile time is: Cannot convert value of type 'Repository' to closure result type '[Any]'
P.S. return type here should be:
-> AnyPublisher<[Repository], Never>
Can anyone share a light here? Many thanks in advance.
It was a silly mistake on my side, as Joakim Danielson pointed out, it was enought to change return type to an array of Repositories and publisher was working fine, final version of it is the following:
static func fetchRepositories(urlString: String) -> AnyPublisher<[Repository], Never> {
// prepare URL
guard let URL = URL(string: urlString) else {
return Just([]).eraseToAnyPublisher()
}
// get handle of native data task publisher
let publisher = URLSession.shared.dataTaskPublisher(for: URL)
.handleEvents(
receiveSubscription: { _ in
activityIndicatorPublisher.send(true)
}, receiveCompletion: { _ in
activityIndicatorPublisher.send(false)
}, receiveCancel: {
activityIndicatorPublisher.send(false)
})
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.httpError
}
return data
}
.decode(type: [Repository].self, decoder: JSONDecoder())
.map { $0 }
.catch { err in
return Just([])
}
.eraseToAnyPublisher()
return publisher
}
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)
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)
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 { }
The API I'm making calls to can return JSON containing error message.
How can I tell Combine to try and decode this custom error if I'm expecting another Decodable object to be returned on successful request?
My code currently looks like this:
private var cancellable: AnyCancellable?
internal func perform<T>(request: URLRequest, completion: #escaping (Result<T, Error>) -> Void) where T: Decodable {
cancellable = session.dataTaskPublisher(for: request)
.tryMap { output in
guard let response = output.response as? HTTPURLResponse, response.statusCode == 200 else {
throw HTTPError.statusCode
}
return output.data
}
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
.sink(receiveCompletion: { _completion in
guard case .failure(let error) = _completion else {
return
}
completion(.failure(error))
}, receiveValue: { value in
completion(.success(value))
})
}
With URLSession I would do something like this:
URLSession.shared.dataTask(with: request) { data, response, error in
// Check for any connection errors
if let error = error {
completion(.failure(error))
return
}
// Read data
guard let data = data, !data.isEmpty else {
completion(.failure(SPTError.noDataReceivedError))
return
}
// Check response's status code, if it's anything other than 200 (OK), try to decode SPTError from the data.
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
let sptError = (try? JSONDecoder().decode(SPTError.self, from: data)) ?? SPTError.badRequest
completion(.failure(sptError))
return
}
// Decode requested objects
do {
let object = try JSONDecoder().decode(T.self, from: data)
completion(.success(object))
} catch {
print(completion(.failure(error)))
}
}.resume()
SPTError is just a struct that contains code and message, it conforms to Codable
When you have conditional branching, you can use the .flatMap to determine which publisher to return based on whatever conditions you check.
FlatMap has to match the failure type of the upstream and the returned publisher, so need to .mapError first to a generic Error. And because different branch is a different publisher chain, type erase them all to AnyPublisher:
URLSession.shared.dataTaskPublisher(for: url)
.mapError { $0 as Error }
.flatMap() { output -> AnyPublisher<T, Error> in
if output.data.isEmpty {
return Fail(error: SPTError.noDataReceivedError).eraseToAnyPublisher()
}
guard let httpResponse = output.response as? HTTPURLResponse else {
return Fail(error: HTTPError.statusCode).eraseToAnyPublisher()
}
if httpResponse.statusCode == 200 {
return Just(output.data)
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
} else {
return Just(output.data)
.decode(type: SPTError.self, decoder: JSONDecoder())
.flatMap { Fail(error: $0) }
.eraseToAnyPublisher()
}
}
.eraseToAnyPublisher()