How to merge two publishers with Combine? - ios

I have an token publisher. It returns a token value as string. I want to make a request using this token in request publisher. I have no idea how to do it. Maybe these codes will help.
Token Transactions:
// Token Publisher
func getAccessToken() -> AnyPublisher<String, Error> {
let url = "it doesn't matter"
var urlRequest = URLRequest(url: url)
// some url request setups
// ...
return URLSession.shared
.dataTaskPublisher(for: urlRequest)
.map(\.data)
.decode(type: AccessToken.self, decoder: JSONDecoder())
.map({ $0.token ?? "" })
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Generic Request Transactions:
// Request Publisher (The T is decodable generic type.)
func request<T: Decodable>(ofType: T.Type, apiURL: APIURL, method: HTTPMethods) -> AnyPublisher<T, Error> {
// This flatMap is not invoked. :/
AuthManager.shared.getAccessToken()
.flatMap({ accessToken -> AnyPublisher<T, Error> in
guard let url = URL(string: apiURL.url) else {
return Fail(error: NSError(domain: "Missing API URL", code: -10001, userInfo: nil)).eraseToAnyPublisher()
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method.rawValue
urlRequest.setValue("Authorization", forHTTPHeaderField: "Bearer " + accessToken)
return URLSession.shared
.dataTaskPublisher(for: urlRequest)
.map(\.data)
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}).eraseToAnyPublisher()
}
Example Request Transactions:
// Example Request
private var cancellables = Set<AnyCancellable>()
func getExampleRequest(id: String) {
let url = "it doesn't matter"
APIManager.shared.request(ofType: ExampleModel.self, apiURL: url, method: .get).sink { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
} receiveValue: { exampleModelData in
// print(exampleModelData)
}.store(in: &cancellables)
}
Thanks in advance.

What you have here is a runtime problem that isn't reproducible given the code you presented. I can't give you an answer but I can help you find the problem.
The .print() operator is your friend. put it just before the flatMap and you should see that you aren't getting a next event, but you will likely see that you are getting a canceled or finished event.
If you are getting a finished event, move the print operator up the publisher chain until you figure out where the problem is. If you are getting a cancelled event, check to see why your cancellable is being deinted before completion.

Related

POST request using Swift Combine without return doesn't work with AnyPublisher<Void, Error>

I have a POST request to send a text message to the user that doesn't return anything, but the status code. I try to return AnyPublisher<Void, CustomError> but it won't work.
enter image description here
This is what my generic request method looks like:
func request<T>(_ req: NetworkRequest) -> AnyPublisher<T, NetworkError> where T: Decodable, T: Encodable {
let sessionConfig = URLSessionConfiguration.default
sessionConfig.timeoutIntervalForRequest = TimeInterval(req.requestTimeout ?? requestTimeout)
guard let url = URL(string: req.url) else {
// Return a fail publisher if the url is invalid
return AnyPublisher(
Fail<T, NetworkError>(error: NetworkError.badURL("Invalid Url"))
)
}
// We use the dataTaskPublisher from the URLSession which gives us a publisher to play around with.
return URLSession.shared
.dataTaskPublisher(for: req.buildURLRequest(with: url))
.tryMap { output in
// throw an error if response is nil
guard output.response is HTTPURLResponse else {
throw NetworkError.serverError(code: 0, error: "Server error")
}
return output.data
}
.decode(type: T.self, decoder: JSONDecoder())
.mapError { error in
// return error if json decoding fails
NetworkError.invalidJSON(String(describing: error))
}
.eraseToAnyPublisher()
}
Looks like the primary issue is that you're trying to turn AnyPublisher<T,NetworkError> into AnyPublisher<Void,NetworkError>.
You could do:
service.request(request).map { _ in () }.eraseToAnyPublisher()

Trouble posting API request with Combine

I'm new to the Combine game and am trying to figure out how to generalize a HTTP POST request.
I created the following APIService class to extend individual resource services from:
import Foundation
import Combine
class APIService {
let decoder: JSONDecoder
let session: URLSession
init(session: URLSession = URLSession.shared, decoder: JSONDecoder = JSONDecoder()) {
self.decoder = decoder
self.session = session
}
}
// MARK: JSON API
extension APIService {
struct Response<T> {
let response: HTTPURLResponse
let value: T
}
func post<T: Codable>(
payload: T,
url: URL
) -> AnyPublisher<Response<T>, APIError> {
return Just(payload)
.setFailureType(to: APIError.self) // <<< THIS WAS MISSING!
.encode(encoder: JSONEncoder())
.flatMap({ [weak self] payload -> AnyPublisher<Data, Error> in
guard let self = self else {
return Fail(error: .default("Failing to establish self.")).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = Methods.post
request.setValue(
Mimetypes.json,
forHTTPHeaderField: Headers.contentType
)
request.httpBody = payload
return self.session
.dataTaskPublisher(
for: request
)
.tryMap { response -> Response<T> in
let value = try self.decoder.decode(T.self, from: response.data)
return Response(
value: value,
response: response.response
)
}
.mapError { error in
return APIError.default(error.localizedDescription)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
})
.eraseToAnyPublisher()
}
}
However, this class won't compile with the following error at the post function.
Type of expression is ambiguous without more context
Being new to Swift in general and Combine in particular, I am unfortunately out of ideas on how to proceed.
Any help is greatly appreciated!
Figured it out myself: Solution
Add a Failure type to the Just, so input and output Failure types to flatMap are equal. Or put differently: flatMap cannot convert the Never failing Just to a Publisher with Failure.
The missing line in my case:
Just(payload)
.setFailureType(to: APIError.self)
You just have a few compile-time mistakes, which the Swift type inference system isn't able to pinpoint when they happen within a notoriously cranky flatMap Combine operator.
First, you're using the wrong order of parameters and the type of URLResponse, in creating a Response object. Correct it to:
return Response(
response: response.response as! HTTPURLResponse,
value: value
)
Second, your flatMap is not actually returning AnyPublisher<Data, Error> - the return type you specified inside its closure. The return type is AnyPublisher<Response<T>, APIError>. So, you can change that, but then you'll run into another problem, which is that the Error type of flatMap has to be the same as its upstream, which currently is not APIError, so I'd suggest just moving the mapError out of flatMap. It would look like this:
return Just(payload)
.encode(encoder: JSONEncoder())
.flatMap({ [weak self] payload -> AnyPublisher<Response<T>, Error> in
guard let self = self else {
return Fail(error: APIError.default("...")).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = "Methods.post"
request.setValue(
Mimetypes.json,
forHTTPHeaderField: Headers.contentType
)
request.httpBody = payload
return self.session
.dataTaskPublisher(
for: request
)
.tryMap { response -> Response<T> in
let value = try self.decoder.decode(T.self, from: response.data)
return Response(
response: response.response as! HTTPURLResponse,
value: value
)
}
.eraseToAnyPublisher()
})
.mapError { error in
return APIError.default(error.localizedDescription)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
Figured it out, thanks to this solid guide to debugging Publishers on another question.
The Just needs to be augmented with a Failure, because flatMap needs the Failure of input and output streams to be the same.
We can use setFailureType(to: <T>) to do so. I have updated my question to reflect this solution.

Alamofire + Combine: Get the HTTP response status code

I am currently using Alamofire which contains Combine support and using it following way:
let request = AF.request(endpoint)
...
request
.publishDecodable(type: T.self, decoder: decoder)
.value()
.eraseToAnyPublisher()
This will publish result and AFError but from subscriber's .sink, I can't find anywhere to get the HTTP status code. What's the best way to get the status code in subscriber?
If you want the response code, don't erase the DataPublisher using .value(). Instead, use the DataResponse you get from the various publish methods, which includes all of the various response information, including status code. You can then .map it into whatever type you need.
For Swift 5.X and Xcode 12.4
For debugging purposes you can intercept the response right before the Combine publisher (publishDecodable()) and get some of the elements of the URL Response, with :
session.request(signedRequest)
.responseJSON { response in
print(response.request) // original URL request
print(response.response) // URL response
print(response.data) // server data
print(response.result) // result of response serialization
}
The easy MVVM way:
func fetchChats() -> AnyPublisher<ChatListModel, AFError> {
let url = URL(string: "Your_URL")!
AF.request(url, method: .get)
.validate()
.publishDecodable(type: ChatListModel.self)
.value()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Later in viewModel
private var subscriptions: Set<AnyCancellable> = []
// some func
dataManager.fetchChats()
.sink {[weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
switch error.responseCode {
case 401:
//do something with code
default:
print(error.responseCode)
}
print("All errors:\(error)")
case .finished:
break
}
} receiveValue: {[weak self] message in
guard let self = self else { return }
self.message = message
}
.store(in: &subscriptions)

Handling errors in Combine (Swift, iOS)

I don't know how to deal with errors in a Combine flow. I would like to be able to catch errors from a Combine function.
Could anyone help in explaining what I'm doing wrong here and how I should handle catching an error with Combine?
Note: The function below is just an example to illustrate a case where an error could be caught instead of crashing the app.
func dataFromURL<T: Decodable>(_ url: String, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, Error> {
// 1) Example: If the URL is not well-formatted, I would like to /create/raise/return an error (in a Combine way)
// 2) Instead of the forced unwrapping here, I would also prefer to raise a catchable error if the creation of the request fails
let request = URLRequest(url: URL(string:url)!)
// 3) Any kind of example dealing with potential errors, etc
return urlSession
.dataTaskPublisher(for: request)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
// function in another file:
func result() {
// I would like to be able to catch or handle errors in this function
dataFromURL("test").print()
// Example : if error 1), else if error 2) etc
}
As explained in the comments, I would like to be able to catch any error outside the dataFromURL function, but in a "Combine way".
I used a URL data fetching as an example, but it could be with anything else.
What is the recommended way to raise and catch errors with the Combine flow? Is it to return a Publisher with a specific error for example? If so, how can I do it?
EDIT
Without Combine, I would just have thrown an error, added the throws keyword to the function, and would have caught the error in the result function.
But I would have expected Combine to have a simpler or more elegant way to achieve this. For example, maybe something that can be thrown at any time:
guard <url is valid> else {
return PublisherError(URLError.urlNotValid)
}
And could have been caught like this:
dataFromURL
.print()
.onError { error in
// handle error here
}
.sink { result in
// no error
}
If the URL(string:) initializer fails (returning nil), you have to decide what error you want to turn that into. Let's say you want to turn it into a URLError. So, if URL(string:) returns nil, create the URLError and use a Fail publisher to publish it:
func jsonContents<T: Decodable>(
ofUrl urlString: String,
as type: T.Type,
decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
guard let url = URL(string: urlString) else {
let error = URLError(.badURL, userInfo: [NSURLErrorKey: urlString])
return Fail(error: error).eraseToAnyPublisher()
}
return URLSession.shared
.dataTaskPublisher(for: url)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
But if you really want to shovel more Combine into it, you can use a Result.Publisher instead of Fail:
func jsonContents<T: Decodable>(
ofUrl urlString: String,
as type: T.Type,
decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
return (
URL(string: urlString)
.map { Result.success($0) } // This is Optional.map
?? Result.failure(URLError(.badURL, userInfo: [NSURLErrorKey: urlString]))
)
.publisher
.flatMap({
URLSession.shared
.dataTaskPublisher(for: $0)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
But things get hard to read. We could factor out the use of Result into a new operator, unwrapOrFail(with:):
extension Publisher {
func unwrapOrFail<Wrapped>(with error: Failure) -> Publishers.FlatMap<Result<Wrapped, Self.Failure>.Publisher, Self> where Output == Wrapped? {
return self
.flatMap ({
$0
.map { Result.success($0).publisher }
?? Result.failure(error).publisher
})
}
}
And then use it like this:
func jsonContents<T: Decodable>(
ofUrl urlString: String,
as type: T.Type,
decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
return Result.success(urlString).publisher
.map { URL(string: $0) }
.unwrapOrFail(with: URLError(.badURL, userInfo: [NSURLErrorKey: urlString]))
.flatMap({
URLSession.shared
.dataTaskPublisher(for: $0)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Note, though, that if you make any mistake along the way, you'll probably get an inscrutable error message and have to pick apart your long pipeline to get Swift to tell you what's really wrong.

Input and output type mismatch in Combine data fetching

I am new to reactive programming and Combine, I have the following method which fetching the weather Data from the API.
First, I am checking that if I get 200 if not then throw an error.
If I get the proper data I am decoding it via JSONDecoder but just to
check if there is some problem JSON decoding I am returning the default Object.
Finally mapping the error which is thrown in the
first step but I am getting the following error in flatMAp function
instance method
flatMap(maxPublishers:_:)' requires the types 'Publishers.TryMap.Failure' (aka 'Error') and 'Just.Failure' (aka 'Never') be equivalent
private func fetchDataFor(urlStr: String) -> AnyPublisher<WeatherData, Error> {
let url = URL(string: urlStr)!
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap({ (data, response) in
let response = (response as? HTTPURLResponse)
if response?.statusCode != 200 {
throw NSError(domain: "Error", code: response!.statusCode, userInfo: .none)
}
return data
})
.flatMap{ data in
Just(data)
.decode(type: WeatherData.self, decoder: JSONDecoder())
.catch{ error in
return Just(defaultWeatherData)
}
}
.mapError{ error in
return error
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
Can somebody guide what is wrong here or I am using some wrong approach. Thanks
Change your function to this:
private func fetchDataFor(urlStr: String) -> AnyPublisher<WeatherData, Error> {
let url = URL(string: urlStr)!
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap({ (data, response) in
let response = (response as? HTTPURLResponse)
if response?.statusCode != 200 {
throw NSError(domain: "Error", code: response!.statusCode, userInfo: .none)
}
return data
})
.decode(type: WeatherData.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
Combine has helpful build-in .decode method, where you can convert your data to the local model.
Also, a good practice is not to use .receive(on: RunLoop.main) in the request methods, but let your ViewModel/Interactor (simply a consumer of response) decide on which thread it wants to receive the response.

Resources