'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.



Solution 1:[1]

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.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Mr Spring