Search code examples
iosswift

Decoding Server Responses for 401 Status Code Using Generic Networking Layer


I got the following code online and I am trying to modify it in a way that I can get access to the decoded response for 401. Server sends the following 401 response as JSON.

{ message: 'Username or password is incorrect', success: false }

But when I use the HTTPClient below it simply throws NetworkError.unauthorized without giving me the message. How can I update the load function to get access of the server response.

struct HTTPClient {
    
    private let session: URLSession
    
    init() {
        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = ["Content-Type": "application/json"]
        self.session = URLSession(configuration: configuration)
    }
    
    func load<T: Codable>(_ resource: Resource<T>) async throws -> T {
        
        var request = URLRequest(url: resource.url)
        
        // Set HTTP method and body if needed
        switch resource.method {
            case .get(let queryItems):
                var components = URLComponents(url: resource.url, resolvingAgainstBaseURL: false)
                components?.queryItems = queryItems
                guard let url = components?.url else {
                    throw NetworkError.badRequest
                }
                request.url = url
                
            case .post(let data), .put(let data):
                request.httpMethod = resource.method.name
                request.httpBody = data
                
            case .delete:
                request.httpMethod = resource.method.name
        }
        
        // Set custom headers
        if let headers = resource.headers {
            for (key, value) in headers {
                request.setValue(value, forHTTPHeaderField: key)
            }
        }
        
        let (data, response) = try await session.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        
        // Check for specific HTTP errors
        switch httpResponse.statusCode {
            case 200...299:
                break // Success
            case 400:
                throw NetworkError.badRequest
            case 401:
                throw NetworkError.unauthorized
            case 403:
                throw NetworkError.forbidden
            case 404:
                throw NetworkError.notFound
            default:
                throw NetworkError.unknownStatusCode(httpResponse.statusCode)
        }
        
        do {
            let result = try JSONDecoder().decode(resource.modelType, from: data)
            return result
        } catch {
            throw NetworkError.decodingError(error)
        }
    }
}

UPDATE:

NetworkError Looks like the following:

extension NetworkError: LocalizedError {
    
    var errorDescription: String? {
        switch self {
            case .badRequest:
                return NSLocalizedString("Bad Request (400): Unable to perform the request.", comment: "badRequestError")
            case .unauthorized:
                return NSLocalizedString("Unauthorized (401): Authentication is required.", comment: "unauthorizedError")
            case .forbidden:
                return NSLocalizedString("Forbidden (403): You don't have permission to access this resource.", comment: "forbiddenError")
            case .notFound:
                return NSLocalizedString("Not Found (404): The requested resource could not be found.", comment: "notFoundError")
            case .serverError(let errorMessage):
                return NSLocalizedString(errorMessage, comment: "serverError")
            case .decodingError:
                return NSLocalizedString("Unable to decode successfully.", comment: "decodingError")
            case .invalidResponse:
                return NSLocalizedString("Invalid response.", comment: "invalidResponse")
            case .unknownStatusCode(let statusCode):
                return NSLocalizedString("Unknown error with status code: \(statusCode).", comment: "unknownStatusCodeError")
        }
    }
}

UPDATE 2:

I updated the load function and now when I get 401 I throw LoginError. Does that look right? Since HTTPClient is a generic network layer it feels weird to throw such specific errors?

 // Check for specific HTTP errors
        switch httpResponse.statusCode {
            case 200...299:
                break // Success
            case 400:
                throw NetworkError.badRequest
            case 401:
                let response = try? JSONDecoder().decode(ErrorResponse.self, from: data)
                throw LoginError.loginFailed(response?.message ?? "Login error")
            case 403:
                throw NetworkError.forbidden
            case 404:
                throw NetworkError.notFound
            default:
                throw NetworkError.unknownStatusCode(httpResponse.statusCode)
        }

UPDATE 3:

Inside the HTTPClient load function, I updated the code to throw the same NetworkError as shown below:

  case 401:
                let response = try JSONDecoder().decode(ErrorResponse.self, from: data)
                throw NetworkError.unauthorized(response)

The caller (AuthenticationController) handles the call and then throw the appropriate error.

func login(username: String, password: String) async throws -> LoginResponse  {
        
        let body = ["username": username, "password": password]
        let bodyData = try JSONEncoder().encode(body)
        
        let resource = Resource(url: Constants.login, method: .post(bodyData), modelType: LoginResponse.self)
        do {
            let response = try await httpClient.load(resource)
            return response
        } catch NetworkError.unauthorized(let errorResponse) {
            throw LoginError.loginFailed(errorResponse.message ?? "Unable to login.")
        } catch {
            throw error
        }
        
    }

Solution

  • I would just simplify NetworkError to:

    enum NetworkError: Error {
        case badRequest
        case invalidResponse
        case decodingError(Error)
        case errorResponse(response: HTTPURLResponse, body: Data)
    }
    

    The first three cases are thrown from load before you receive the HTTPURLResponse. After you got the response, just throw the errorResponse case and let the caller deal with it in their own way.

    let (data, response) = try await session.data(for: request)
    
    guard let httpResponse = response as? HTTPURLResponse else {
        throw NetworkError.invalidResponse
    }
    
    switch httpResponse.statusCode {
        case 200...299:
            break
        default:
            throw NetworkError.errorResponse(response: httpResponse, body: data)
    }
    

    Alternatively, if a Resource<T> knows the type of error it will get, you can add an additional type parameter:

    struct Resource<T, Failure> where T: Codable, Failure: Error, Failure: Codable {
        // ...
    }
    

    Then, you can write load as:

    func load<T, Failure>(_ resource: Resource<T, Failure>) async throws -> T
        where T: Codable, Failure: Codable, Failure: Error
        {
    
        // ...
    
        switch httpResponse.statusCode {
            case 200...299:
                break
            default:
                let error = try JSONDecoder().decode(Failure.self, from: data)
                throw error
        }
    }
    

    You can also decode the error conditionally - check if Failure.self == Never.self. If that is true, throw a NetworkError.errorResponse(...) as before. This way, you can have Resources that don't know what their error response will be, written as Resource<SomeModel, Never>.

    Example usage:

    struct Person: Codable {
        struct ErrorResponse: Error, Codable {
            let message: String
            let success: Bool
        }
        
        let name: String
        let age: Int
    }
    
    func example() async throws {
        let resource: Resource<Person, Person.ErrorResponse> = ...
        do {
            print(try await load(resource))
        } catch let error as NetworkError {
            // ...
        } catch let error as Person.ErrorResponse {
            // ...
        }
    }