Combine handle different type of publishers - swift

I am really new to Combine and I'm stuck with this issue. I have basic registration form, that returns empty response with 200 code if everything is ok and 442 if form has some registration failures.
That's the code that can handle empty response and works fine
extension Route where ResultType: EmptyResult {
func emptyResult() -> AnyPublisher<Void, APIError> {
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.print("EMPTY RESULT")
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else { throw APIError.nonHttpResponse(description: "Not http resp") }
let statusCode = httpResponse.statusCode
guard (200..<300).contains(statusCode) else { throw APIError.nonHttpResponse(description: "bad response")
}
return Void()
}.mapError { error in
print("Error \(error)")
return .network(description: error.localizedDescription)
}
.eraseToAnyPublisher()
}
}
However, how I could return publisher with other type? For example
struct CustomError: Decodable {
let usernameError: String
let emailError: String
}
My network call:
API.registration(name: name, email: email, password: password, schoolID: selectedSchool?.id ?? 0)
.print("Registration")
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case let .failure(error):
print("ERROR \(error)")
case .finished: break
}
}, receiveValue: { value in
print(value)
})
.store(in: &disposables)

So you have a network request, which in case of a successful request, returns a 200 response and an empty body, while in case of a form error, it returns a specific status code and an error in the response.
I would suggest keeping the Output type of your Publisher as Void, however, in case of a form error, decoding the error and throwing it as part of your APIError.
struct LoginError: Decodable {
let usernameError: String
let emailError: String
}
enum APIError: Error {
case failureStatus(code: Int)
case login(LoginError)
case nonHttpResponse(description: String)
case network(Error)
}
func emptyResult() -> AnyPublisher<Void, APIError> {
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.print("EMPTY RESULT")
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else { throw APIError.nonHttpResponse(description: "Not http response") }
let statusCode = httpResponse.statusCode
guard (200..<300).contains(statusCode) else {
if statusCode == 442 {
let loginError = try JSONDecoder().decode(LoginError.self, from: data)
throw APIError.login(loginError)
} else {
throw APIError.failureStatus(code: statusCode)
}
}
return Void()
}.mapError { error in
switch error {
case let apiError as APIError:
return apiError
default:
return .network(error)
}
}
.eraseToAnyPublisher()
}
Then you can handle the specific error by switching over the error in sink:
API.registration(name: name, email: email, password: password, schoolID: selectedSchool?.id ?? 0)
.print("Registration")
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case let .failure(error):
switch error {
case .login(let loginError):
print("Login failed, \(loginError.emailError), \(loginError.usernameError)")
default:
print(error)
}
case .finished: break
}
}, receiveValue: { value in
print(value)
})
.store(in: &disposables)

Related

URLSession.shared.dataTaskPublisher receive cancel

trying to fetch some data with dataTaskPublisher. however, constantly receive following log. it works every once in a while and not sure what's the difference. change URL does not make a difference. still only occasionally succeed the request.
Test2: receive subscription: (TryMap)
Test2: request unlimited
Test2: receive cancel
class DataSource: NSObject, ObservableObject {
var networker: Networker = Networker()
func fetch() {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
fatalError("Invalid URL")
}
networker.fetchUrl(url: url)
}
}
class Networker: NSObject, ObservableObject {
var pub: AnyPublisher<Data, Error>? = nil
var sub: Cancellable? = nil
var data: Data? = nil
var response: URLResponse? = nil
func fetchUrl(url: URL) {
guard let url = URL(string: "https://apple.com") else {
return
}
pub = URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap() { data, response in
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
.print("Test2")
.eraseToAnyPublisher()
sub = pub?.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
fatalError(error.localizedDescription)
}
},
receiveValue: {
print($0)
}
)
}
add .store(in: &subscriptions)

How to decode error response message in Combine?

I'm doing login using SwiftUI and Combine. Could you please give me some idea how can I decode and show json error when user types incorrect email or password? I can only get token.
When I'm doing the same login request with incorrect email or password, server returns me this error message:
{
"code": "[jwt_auth] incorrect_password",
"message": "Incorrect password!",
"data": {
"status": 403
}
}
The problem is that I can't understand how can I decode two different json responses when doing one request in Combine? I can only get token.
Here's model for login request:
struct LoginResponse: Decodable {
let token: String }
struct ErrorResponse: Decodable {
let message: String
}
struct Login: Codable {
let username: String
let password: String
}
static func login(email: String, password: String) -> AnyPublisher<LoginResponse, Error> {
let url = MarketplaceAPI.jwtAuth!
var request = URLRequest(url: url)
let encoder = JSONEncoder()
let login = Login(username: email, password: password)
let jsonData = try? encoder.encode(login)
request.httpBody = jsonData
request.httpMethod = HTTPMethod.POST.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared
.dataTaskPublisher(for: request)
.print()
.receive(on: DispatchQueue.main)
.map(\.data)
.decode(
type: LoginResponse.self,
decoder: JSONDecoder())
.eraseToAnyPublisher()
}
And in viewModel:
MarketplaceAPI.login(email: email, password: password)
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("finished")
case .failure(let error):
print("Failure error:", error.localizedDescription) // This's returning token error
}
},
receiveValue: { value in
print("Token:", value.token)
}
})
.store(in: &subscriptions)
}
I would make ErrorResponse conform to the Error protocol:
struct ErrorResponse: Decodable, Error {
let message: String
}
Then, use tryMap instead of decode (which is sort of a special case of tryMap).
.tryMap({ data -> LoginResponse in
let decoder = JSONDecoder()
guard let loginResponse = try? decoder.decode(LoginResponse.self, from: data) else {
throw try decoder.decode(ErrorResponse.self, from: data)
}
return loginResponse
})
First, try to decode the data as a LoginResponse. Note the use of try? here. This is so that we can check whether this has failed or not. If this fails, we throw an error. The error we throw is the data decoded as an ErrorResponse, or whatever error is thrown during the decoding of that.
In your view model, you can check the error like this:
.sink { completion in
switch completion {
case .failure(let error as ErrorResponse):
// wrong password/username
// you can access error.message here
case .failure(let error):
// some other sort of error:
default:
break
}
} receiveValue: { loginResponse in
...
}
You can make use of tryMap with combine to figure out where the function should return. I'd suggest you take a read of the documentation on it but here is a snippet that should be able to get you moving with it.
Hopefully this is what you mean by the question - I've changed a few things but feel free to take the code as a building block and adapt as needed!
enum LoginError: Error, Equatable {
case noConnection
case invalidResponse
}
static func login(email: String, password: String) -> AnyPublisher<Void, LoginError> {
return URLSession.shared
.dataTaskPublisher(for: request)
.print()
.receive(on: DispatchQueue.main)
.mapError { _ in LoginError.noConnection }
.tryMap { (data, response) in
guard let response = response as? HTTPURLResponse else {
throw LoginError.invalidResponse
}
if response.statusCode == 200 {
return data
} else {
throw LoginError.invalidResponse
}
}
.decode(type: LoginResponse.self, decoder: JSONDecoder())
.tryMap { [unowned self] in
// Update your session with the token here using $0 then return.
// e.g session.token = $0.token
return
}
.mapError { $0 as? LoginError ?? .invalidResponse }
.eraseToAnyPublisher()
}

Swift Combine Completion Handler with return of values

I have an API Service handler implemented which uses an authentication token in the header of the request. This token is fetched when the user logs in at the launch of the application. After 30 minutes, the token is expired. Thus, when a request is made after this timespan, the API returns an 403 statuscode. The API should then login again and restart the current API request.
The problem I am encountering is that the login function to fetch a new token, makes use of a completion handler to let the calling code know if the asynchronous login procedure has been successful or not. When the API gets a 403 statuscode, it calls the login procedure and and when that is complete, it should make the current request again. But this repeated API request should return some value again. However, returning a value is not possible in a completion block. Does anyone know a solution for the problem as a whole?
The login function is as follows:
func login (completion: #escaping (Bool) -> Void) {
self.loginState = .loading
let preparedBody = APIPrepper.prepBody(parametersDict: ["username": self.credentials.username, "password": self.credentials.password])
let cancellable = service.request(ofType: UserLogin.self, from: .login, body: preparedBody).sink { res in
switch res {
case .finished:
if self.loginResult.token != nil {
self.loginState = .success
self.token.token = self.loginResult.token!
_ = KeychainStorage.saveCredentials(self.credentials)
_ = KeychainStorage.saveAPIToken(self.token)
completion(true)
}
else {
(self.banner.message, self.banner.stateIdentifier, self.banner.type, self.banner.show) = ("ERROR", "TOKEN", "error", true)
self.loginState = .failed(stateIdentifier: "TOKEN", errorMessage: "ERROR")
completion(false)
}
case .failure(let error):
(self.banner.message, self.banner.stateIdentifier, self.banner.type, self.banner.show) = (error.errorMessage, error.statusCode, "error", true)
self.loginState = .failed(stateIdentifier: error.statusCode, errorMessage: error.errorMessage)
completion(false)
}
} receiveValue: { response in
self.loginResult = response
}
self.cancellables.insert(cancellable)
}
The API service is as follows:
func request<T: Decodable>(ofType type: T.Type, from endpoint: APIRequest, body: String) -> AnyPublisher<T, Error> {
var request = endpoint.urlRequest
request.httpMethod = endpoint.method
if endpoint.authenticated == true {
request.setValue(KeychainStorage.getAPIToken()?.token, forHTTPHeaderField: "token")
}
if !body.isEmpty {
let finalBody = body.data(using: .utf8)
request.httpBody = finalBody
}
return URLSession
.shared
.dataTaskPublisher(for: request)
.receive(on: DispatchQueue.main)
.mapError { _ in Error.unknown}
.flatMap { data, response -> AnyPublisher<T, Error> in
guard let response = response as? HTTPURLResponse else {
return Fail(error: Error.unknown).eraseToAnyPublisher()
}
let jsonDecoder = JSONDecoder()
if response.statusCode == 200 {
return Just(data)
.decode(type: T.self, decoder: jsonDecoder)
.mapError { _ in Error.decodingError }
.eraseToAnyPublisher()
}
else if response.statusCode == 403 {
let credentials = KeychainStorage.getCredentials()
let signinModel: SigninViewModel = SigninViewModel()
signinModel.credentials = credentials!
signinModel.login() { success in
if success == true {
-------------------> // MAKE THE API CALL AGAIN AND THUS RETURN SOME VALUE
}
else {
-------------------> // RETURN AN ERROR
}
}
}
else if response.statusCode == 429 {
return Fail(error: Error.errorCode(statusCode: response.statusCode, errorMessage: "Oeps! Je hebt teveel verzoeken gedaan, wacht een minuutje")).eraseToAnyPublisher()
}
else {
do {
let errorMessage = try jsonDecoder.decode(APIErrorMessage.self, from: data)
return Fail(error: Error.errorCode(statusCode: response.statusCode, errorMessage: errorMessage.error ?? "Er is iets foutgegaan")).eraseToAnyPublisher()
}
catch {
return Fail(error: Error.decodingError).eraseToAnyPublisher()
}
}
}
.eraseToAnyPublisher()
}
You're trying to combine Combine with old asynchronous code. You can do it with Future, check out more about it in this apple article:
Future { promise in
signinModel.login { success in
if success == true {
promise(Result.success(()))
}
else {
promise(Result.failure(Error.unknown))
}
}
}
.flatMap { _ in
// repeat request if login succeed
request(ofType: type, from: endpoint, body: body)
}.eraseToAnyPublisher()
But this should be done when you cannot modify the asynchronous method or most of your codebase uses it.
In your case it looks like you can rewrite login to Combine. I can't build your code, so there might be errors in mine too, but you should get the idea:
func login() -> AnyPublisher<Void, Error> {
self.loginState = .loading
let preparedBody = APIPrepper.prepBody(parametersDict: ["username": self.credentials.username, "password": self.credentials.password])
return service.request(ofType: UserLogin.self, from: .login, body: preparedBody)
.handleEvents(receiveCompletion: { res in
if case let .failure(error) = res {
(self.banner.message,
self.banner.stateIdentifier,
self.banner.type,
self.banner.show) = (error.errorMessage, error.statusCode, "error", true)
self.loginState = .failed(stateIdentifier: error.statusCode, errorMessage: error.errorMessage)
}
})
.flatMap { loginResult in
if loginResult.token != nil {
self.loginState = .success
self.token.token = loginResult.token!
_ = KeychainStorage.saveCredentials(self.credentials)
_ = KeychainStorage.saveAPIToken(self.token)
return Just(Void()).eraseToAnyPublisher()
} else {
(self.banner.message, self.banner.stateIdentifier, self.banner.type, self.banner.show) = ("ERROR",
"TOKEN",
"error",
true)
self.loginState = .failed(stateIdentifier: "TOKEN", errorMessage: "ERROR")
return Fail(error: Error.unknown).eraseToAnyPublisher()
}
}
.eraseToAnyPublisher()
}
And then call it like this:
signinModel.login()
.flatMap { _ in
request(ofType: type, from: endpoint, body: body)
}.eraseToAnyPublisher()

How could I throw an Error when response data does not contain an object to decode with Combine?

I have a publisher wrapper struct where I can handle response status code. If the status code is not range in 200..300 it return with an object, otherwise it throws an Error. It works well.
public func anyPublisher<T:Decodable>(type: T.Type) -> AnyPublisher<T, Error> {
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap { output in
guard let httpResponse = output.response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
throw APIError.unknown
}
return output.data
}
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Using:
let sendNewUserPublisher = NetworkPublisher(urlRequest: request).anyPublisher(type: User.self)
cancellationToken = sendNewUserPublisher.sink(receiveCompletion: { completion in
if case let .failure(error) = completion {
NSLog("error: \(error.localizedDescription)")
}
}, receiveValue: { post in
self.post = post
})
As above, I would like to handle the error even if the response data does not contain an object to be decoded.
public func anyPublisher() -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure> {
return URLSession.shared.dataTaskPublisher(for: urlRequest)
// I'd like to handle status code here, and throw an error, if needed
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Thank you in advance for any help you can provide.
I would suggest creating a Publisher that handles the HTTP response status code validation and using that for both of your other publishers - the one that handles an empty request body and the one that decodes the body.
If you need the HTTPURLResponse object even after validating its status code:
extension URLSession.DataTaskPublisher {
/// Publisher that throws an error in case the data task finished with an invalid status code, otherwise it simply returns the body and response of the HTTP request
func httpResponseValidator() -> AnyPublisher<Output, CustomError> {
tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else { throw CustomError.nonHTTPResponse }
let statusCode = httpResponse.statusCode
guard (200..<300).contains(statusCode) else { throw CustomError.incorrectStatusCode(statusCode) }
return (data, httpResponse)
}
.mapError { CustomError.network($0) }
.eraseToAnyPublisher()
}
}
Or if you don't care about any other properties of the response, only that its status code was valid:
func httpResponseValidator() -> AnyPublisher<Data, CustomError> {
tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else { throw CustomError.nonHTTPResponse }
let statusCode = httpResponse.statusCode
guard (200..<300).contains(statusCode) else { throw CustomError.incorrectStatusCode(statusCode) }
return data
}
.mapError { CustomError.network($0) }
.eraseToAnyPublisher()
}
Then you can use this to rewrite both versions of your anyPublisher function:
extension URLSession.DataTaskPublisher {
func anyPublisher<T:Decodable>(type: T.Type) -> AnyPublisher<T, Error> {
httpResponseValidator()
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func anyPublisher() -> AnyPublisher<Output, CustomError> {
httpResponseValidator()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

Swift combine handling HTTP status code errors

I was reading this article: https://www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios on Ray Wenderlich about how to use combine. They have an example where it fetches data from an API but it doesn't handle HTTP status codes. I wanted to add it but so far I'm not able to do so.
According to this answer you could add a tryMap but then XCode starts showing errors like: Generic parameter 'T' could not be inferred.
Below the code:
extension WeatherFetcher: WeatherFetchable {
func weeklyWeatherForecast(
forCity city: String
) -> AnyPublisher<WeeklyForecastResponse, WeatherError> {
return forecast(with: makeWeeklyForecastComponents(withCity: city))
}
private func forecast<T>(
with components: URLComponents
) -> AnyPublisher<T, WeatherError> where T: Decodable {
guard let url = components.url else {
let error = WeatherError.network(description: "Couldn't create URL")
return Fail(error: error).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: URLRequest(url: url))
.mapError { error in
.network(description: error.localizedDescription)
}
.flatMap(maxPublishers: .max(1)) { pair in
decode(pair.data)
}
.eraseToAnyPublisher()
}
}
And I was trying to add
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
switch (response as! HTTPURLResponse).statusCode {
case (400...499):
throw ServiceErrors.internalError((response as! HTTPURLResponse).statusCode)
default:
throw ServiceErrors.serverError((response as! HTTPURLResponse).statusCode)
}
}
return data
}
I think you can simply replace the flatMap block with tryMap. And instead of returning data from tryMap, it should be decoded T. So return data line should be return try JSONDecoder().decode(T.self, from: data)
private func forecast<T>(with components: URLComponents) -> AnyPublisher<T, WeatherError> where T: Decodable {
guard let url = components.url else {
let error = WeatherError.network(description: "Couldn't create URL")
return Fail(error: error).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: URLRequest(url: url))
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
switch (response as! HTTPURLResponse).statusCode {
case (400...499):
throw ServiceErrors.internalError((response as! HTTPURLResponse).statusCode)
default:
throw ServiceErrors.serverError((response as! HTTPURLResponse).statusCode)
}
}
return try JSONDecoder().decode(T.self, from: data)
}
.mapError { error in
WeatherError() // some kind of error
}
.eraseToAnyPublisher()
}