How to decode error response message in Combine? - swift

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()
}

Related

How Do I relogin after getting a 401 back using URLSession and CombineAPI

I'm new to combine and URLSession and I'm trying to find a way to log in after I get a 401 error back. My Set up for the URLSession.
APIErrors:
enum APIError: Error {
case requestFailed
case jsonConversionFailure
case invalidData
case responseUnsuccessful
case jsonParsingFailure
case authorizationFailed
var localizedDescription: String{
switch self{
case .requestFailed: return "Request Failed"
case .invalidData: return "Invalid Data"
case .responseUnsuccessful: return "Response Unsuccessful"
case .jsonParsingFailure: return "JSON Parsing Failure"
case .jsonConversionFailure: return "JSON Conversion Failure"
case .authorizationFailed: return "Failed to login the user."
}
}
}
The CombinAPI itself, I'm trying to catch the 401 either in .catch or .tryCatch, but proving not as easy as I thought.
//1- A Protocol that has an URLSession and a function that returns a publisher.
protocol CombineAPI{
var session: URLSession { get}
// var authenticationFeed: AuthenticationFeed { get }
func execute<T>(_ request: URLRequest, decodingType: T.Type, queue: DispatchQueue, retries: Int) -> AnyPublisher<T, Error> where T: Decodable
//func reauthenticate<T>(_ request: URLRequest, decodingType: T.Type, queue: DispatchQueue, retries: Int) -> AnyPublisher<T, Error> where T: Decodable
}
//2 - Extending CombineAPI so we can have a default implementation.
extension CombineAPI {
func authenticationFeed() -> URLRequest{
return AuthenticationFeed.login(parameters: UserCredentials(userName: UserSettings.sharedInstance.getEmail(), password: UserSettings.sharedInstance.getPassword())).request
}
func execute<T>(_ request: URLRequest,
decodingType: T.Type,
queue: DispatchQueue = .main,
retries: Int = 0) -> AnyPublisher<T, Error> where T: Decodable{
return session.dataTaskPublisher(for: request)
.tryMap {
guard let response = $0.response as? HTTPURLResponse, response.statusCode == 200 else{
let response = $0.response as? HTTPURLResponse
if response?.statusCode == 401{
throw APIError.authorizationFailed
}
print(response!.statusCode)
throw APIError.responseUnsuccessful
}
//Return the data if everything is good
return $0.data
}
.catch(){ _ in
//Try to relogin here or in tryCatch block
}
// .tryCatch { error in
// if Error as? APIError == .authorizationFailed {
// let subcription = self.callFunction().switchToLatest().flatMap { session.dataTaskPublisher(for: request)}.eraseToAnyPublisher()
// return subcription
// }else{
// throw APIError.responseUnsuccessful
// }
// }
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: queue)
.retry(retries)
.eraseToAnyPublisher()
}
func reauthenticate<T>( decodingType: Token.Type, queue: DispatchQueue = .main,retries: Int = 2) -> AnyPublisher<T, Error> where T: Decodable{
return session.dataTaskPublisher(for: self.authenticationFeed())
.tryMap{
guard let response = $0.response as? HTTPURLResponse, response.statusCode == 200 else{
let response = $0.response as? HTTPURLResponse
if response?.statusCode == 401{
throw APIError.authorizationFailed
}
print(response!.statusCode)
throw APIError.responseUnsuccessful
}
//Return the data if everything is good
return $0.data
}
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: queue)
.retry(retries)
.eraseToAnyPublisher()
}
}
This is the feed that will create the URL request itself:
enum UserFeed{
case getUser(userId: Int)
}
extension UserFeed: Endpoint{
var base: String {
return "http://192.168.1.15:8080"
}
var path: String {
switch self{
case .getUser(let userId): return "/api/v1/User/\(userId)"
}
}
var request: URLRequest{
let url = urlComponents.url!
var request = URLRequest(url: url)
switch self{
case .getUser(_):
request.httpMethod = CallType.get.rawValue
request.setValue("*/*", forHTTPHeaderField: "accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(token,forHTTPHeaderField: "tokenheader")
print(token)
return request
}
}
}
Then the client itself where you would create this would be in your viewModel, so you can make the web request for that type of data:
import Foundation
import Combine
final class UserClient: CombineAPI{
var authenticate = PassthroughSubject<Token, Error>()
var session: URLSession
init(configuration: URLSessionConfiguration){
self.session = URLSession(configuration: configuration)
}
convenience init(){
self.init(configuration: .default)
}
func getFeedUser(_ feedKind: UserFeed) -> AnyPublisher<User, Error>{
return execute(feedKind.request, decodingType: User.self, retries: 2)
}
}
I keep trying to make a new request to my authenticationClient, but it returns a different data type, so the ComineAPI doesn't like it. I'm not sure what I should do, otherwise, it works great until I have to authenticate, or get a new token? Any help would be appreciated, thanks.
I Just need it to log in, so I can save the new token to user settings and then continue on the request it left off with, If I can't get a new token, then I return an error to have the user login.
So if I understood correctly you want to be able to catch error 401 and send a different API request from the one you were previously using.
In that case you want to perform the following just as you wrote:
func execute<T>(_ request: URLRequest,
decodingType: T.Type,
queue: DispatchQueue = .main,
retries: Int = 0) -> AnyPublisher<T, APIError> where T: Decodable{
return session.dataTaskPublisher(for: request)
.mapError { error in
if error.statusCode == 401{
return APIError.authorizationFailed
}
return APIError.someOtherGenericError
}
.map{$0}
.decode(type: T.self, decoder: JSONDecoder())
.catch(){ error in
if error == .authorizationFailed {
return session.dataTaskPublisher(for: request) // a new URLRequest that will call for the token generation.
}
}
.receive(on: queue)
.eraseToAnyPublisher()
}
Now you can use the function catch, you need to return a publisher with the same value as you provided, meaning if you defined that you want a "User" object to be returned, then the catch new publisher will have to return the same type of object.
If you want to make it more generic, you can either handle the .catch request for generating token in the .sink closure, or maybe create a function specific for signing in and not using the generic one.
Sorry for giving so little choices, these solutions are the only things that came up from the top of my head.
Hope it helps.

Alamofire - How to get API error from AFError

In my quest to implement Alamofire 5 correctly and handle custom error model responses, I have yet to find an accepted answer that has an example.
To be as thorough as possible, here is my apiclient
class APIClient {
static let sessionManager: Session = {
let configuration = URLSessionConfiguration.af.default
configuration.timeoutIntervalForRequest = 30
configuration.waitsForConnectivity = true
return Session(configuration: configuration, eventMonitors: [APILogger()])
}()
#discardableResult
private static func performRequest<T:Decodable>(route:APIRouter, decoder: JSONDecoder = JSONDecoder(), completion:#escaping (Result<T, AFError>)->Void) -> DataRequest {
return sessionManager.request(route)
// .validate(statusCode: 200..<300) // This will kill the server side error response...
.responseDecodable (decoder: decoder){ (response: DataResponse<T, AFError>) in
completion(response.result)
}
}
static func login(username: String, password: String, completion:#escaping (Result<User, AFError>)->Void) {
performRequest(route: APIRouter.login(username: username, password: password), completion: completion)
}
}
I am using it like this
APIClient.login(username: "", password: "") { result in
debugPrint(result)
switch result {
case .success(let user):
debugPrint("__________SUCCESS__________")
case .failure(let error):
debugPrint("__________FAILURE__________")
debugPrint(error.localizedDescription)
}
}
I have noticed that if I use .validate() that the calling function will receive a failure however the response data is missing. Looking around it was noted here and here to cast underlyingError but thats nil.
The server responds with a parsable error model that I need at the calling function level. It would be far more pleasant to deserialize the JSON at the apiclient level and return it back to the calling function as a failure.
{
"errorObject": {
"summary": "",
"details": [{
...
}]
}
}
UPDATE
Thanks to #GIJoeCodes comment I implemented this similar solution using the router.
class APIClient {
static let sessionManager: Session = {
let configuration = URLSessionConfiguration.af.default
configuration.timeoutIntervalForRequest = 30
configuration.waitsForConnectivity = true
return Session(configuration: configuration, eventMonitors: [APILogger()])
}()
#discardableResult
private static func performRequest<T:Decodable>(route:APIRouter, decoder: JSONDecoder = JSONDecoder(), completion:#escaping (_ response: T?, _ error: Error?)->Void) {
sessionManager.request(route)
.validate(statusCode: 200..<300) // This will kill the server side error response...
.validate(contentType: ["application/json"])
.responseJSON { response in
guard let data = response.data else { return }
do {
switch response.result {
case .success:
let object = try decoder.decode(T.self, from: data)
completion(object, nil)
case .failure:
let error = try decoder.decode(ErrorWrapper.self, from: data)
completion(nil, error.error)
}
} catch {
debugPrint(error)
}
}
}
// MARK: - Authentication
static func login(username: String, password: String, completion:#escaping (_ response: User?, _ error: Error?)->Void) {
performRequest(route: APIRouter.login(username: username, password: password), completion: completion)
}
}
Called like this
APIClient.login(username: "", password: "") { (user, error) in
if let error = error {
debugPrint("__________FAILURE__________")
debugPrint(error)
return
}
if let user = user {
debugPrint("__________SUCCESS__________")
debugPrint(user)
}
}
This is how I get the errors and customize my error messages. In the validation, I get the errors outside of the 200..<300 response:
AF.request(
url,
method: .post,
parameters: json,
encoder: JSONParameterEncoder.prettyPrinted,
headers: headers
).validate(statusCode: 200..<300)
.validate(contentType: ["application/json"])
.responseJSON { response in
switch response.result {
case .success(let result):
let json = JSON(result)
onSuccess()
case .failure(let error):
guard let data = response.data else { return }
do {
let json = try JSON(data: data)
let message = json["message"]
onError(message.rawValue as! String)
} catch {
print(error)
}
onError(error.localizedDescription)
}
debugPrint(response)
}
First, there's no need to use responseJSON if you already have a Decodable model. You're doing unnecessary work by decoding the response data multiple times. Use responseDecodable and provide your Decodable type, in this case your generic T. responseDecodable(of: T).
Second, wrapping your expected Decodable types in an enum is a typical approach to solving this problem. For instance:
enum APIResponse<T: Decodable> {
case success(T)
case failure(APIError)
}
Then implement APIResponse's Decodable to try to parse either the successful type or APIError (there are a lot of examples of this). You can then parse your response using responseDecodable(of: APIResponse<T>.self).

Swift: How to perform concurrent API calls using Combine

I am attempting to perform concurrent API calls using the Combine framework. The API calls are set up like so:
First, call an API to get a list of Posts
For each post, call another API to get Comments
I would like to use Combine to chain these two calls together and concurrently so that it returns an array of Post objects with each post containing the comments array.
My attempt:
struct Post: Decodable {
let userId: Int
let id: Int
let title: String
let body: String
var comments: [Comment]?
}
struct Comment: Decodable {
let postId: Int
let id: Int
let name: String
let email: String
let body: String
}
class APIClient: ObservableObject {
#Published var posts = [Post]()
var cancellables = Set<AnyCancellable>()
init() {
getPosts()
}
func getPosts() {
let urlString = "https://jsonplaceholder.typicode.com/posts"
guard let url = URL(string: urlString) else {return}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Post].self, decoder: JSONDecoder())
.sink { (completion) in
print("Posts completed: \(completion)")
} receiveValue: { (output) in
//Is there a way to chain getComments such that receiveValue would contain Comments??
output.forEach { (post) in
self.getComments(post: post)
}
}
.store(in: &cancellables)
}
func getComments(post: Post) {
let urlString = "https://jsonplaceholder.typicode.com/posts/\(post.id)/comments"
guard let url = URL(string: urlString) else {
return
}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Comment].self, decoder: JSONDecoder())
.sink { (completion) in
print("Comments completed: \(completion)")
} receiveValue: { (output) in
print("Comment", output)
}
.store(in: &cancellables)
}
}
How do I chain getComments to getPosts so that the output of comments can be received in getPosts? Traditionally using UIKit, I would use DispatchGroup for this task.
Note that I would like to receive just a single Publisher event for posts from the APIClient so that the SwiftUI view is refreshed only once.
Thanks to #matt's post in the comments above, I've adapted the solution in that SO post for my use case above.
Not too sure if it is the best implementation, but it addresses my problem for now.
func getPosts() {
let urlString = "https://jsonplaceholder.typicode.com/posts"
guard let url = URL(string: urlString) else {return}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Post].self, decoder: JSONDecoder())
.flatMap({ (posts) -> AnyPublisher<Post, Error> in
//Because we return an array of Post in decode(), we need to convert it into an array of publishers but broadcast as 1 publisher
Publishers.Sequence(sequence: posts).eraseToAnyPublisher()
})
.compactMap({ post in
//Loop over each post and map to a Publisher
self.getComments(post: post)
})
.flatMap {$0} //Receives the first element, ie the Post
.collect() //Consolidates into an array of Posts
.sink(receiveCompletion: { (completion) in
print("Completion:", completion)
}, receiveValue: { (posts) in
self.posts = posts
})
.store(in: &cancellables)
}
func getComments(post: Post) -> AnyPublisher<Post, Error>? {
let urlString = "https://jsonplaceholder.typicode.com/posts/\(post.id)/comments"
guard let url = URL(string: urlString) else {
return nil
}
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Comment].self, decoder: JSONDecoder())
.tryMap { (comments) -> Post in
var newPost = post
newPost.comments = comments
return newPost
}
.eraseToAnyPublisher()
return publisher
}
Essentially, we will need to return a Publisher from the getComments method so that we can loop over each publisher inside getPosts.

Combine handle different type of publishers

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)

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()
}
}