Handle backend validation errors with URLSession concurrency

Is it possible to decode an error response from the server which has unknown key inside the object? and how will I handle such a response?

Right now I made an extension on URLSession like so

extension URLSession {
    func post<T: Decodable, U: Encodable>(
        _ type: T.Type = T.self,
        data: U,
        from url: URL,
        keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .convertFromSnakeCase
    ) async throws  -> T {
        var request = URLRequest(url: url)
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpMethod = "POST"
        
        let body = try JSONEncoder().encode(data)
        
        let token = UserDefaults.standard.string(forKey: "AccessToken")
        if token != nil {
            request.setValue("Bearer \(token!)", forHTTPHeaderField: "Authorization")
        }
        do {
            let (data, response) = try await upload(for: request, from: body)
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = keyDecodingStrategy

            let decoded = try decoder.decode(T.self, from: data)
            return decoded
        } catch {
            print(error)
            throw error
        }
        
    }
}

This way I can make a POST request to the backend and get a decoded object back So when i try to login I can do this:

    func login(email: String, password: String) async {
        let body = LoginRequest(email: email, password: password)
        let url = URL(string: "https://api.junoreader.com/api/auth/login")!
        do {
            let response = try await URLSession.shared.post(DetailModel<UserModel>.self, data: body, from: url)
            setUser(data: response.data)
        } catch {
            print("api error")
            print(error)
        }
    }

where the response looks like

struct DetailModel<T: Codable>: Codable {
    var data: T
}
struct UserModel: Codable, Identifiable, ObservableObject {
    let id: Int
    let firstName: String
    let lastName: String
    let username: String
    let bio: String

    var name: String {
        return "\(firstName ?? "") \(lastName ?? "")"
    }
}

But when the login credentials are wrong the server responds with a 403 with a JSON object like so

{
  "data": {
    "errors": {
      "login": [
        "Email/password do not match."
      ]
    }
  }
}

the data and error keys are always there but the 'login' could be different based on the request I do and the validation on the backend. so 'error' could also have multiple keys.

What is the best way to decode such an error object and how can I handle these errors inside the 'post' function? also when the error happens instead of throwing an error right away swift is still trying to decode the data.


Solution 1:

You can define an object for API errors:

struct ApiErrorPayload: Decodable {
    let errors: [String: [String]]
}

You can then define an Error enumeration so that you can throw this error (and general HTTP errors):

enum ApiError: Error {
    case serviceError(ApiErrorPayload)
    case httpError(Data, HTTPURLResponse)
}

Then you can define your post method to check the status code and decode either your error object or your ApiErrors:

extension URLSession {
    func post<T: Decodable, U: Encodable>(
        _ type: T.Type = T.self,
        data: U,
        from url: URL,
        keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .convertFromSnakeCase
    ) async throws  -> T {
        var request = URLRequest(url: url)
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpMethod = "POST"

        let body = try JSONEncoder().encode(data)

        if let token = UserDefaults.standard.string(forKey: "AccessToken") {
            request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
        }

        let (data, response) = try await upload(for: request, from: body)
        guard let response = response as? HTTPURLResponse else {
            throw URLError(.badServerResponse)
        }

        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = keyDecodingStrategy

        switch response.statusCode {
        case 200 ..< 300:
            return try decoder.decode(T.self, from: data)

        case 400 ..< 500:
            let payload = try decoder.decode(ApiErrorPayload.self, from: data)
            throw ApiError.serviceError(payload)

        default:
            throw ApiError.httpError(data, response)
        }
    }
}

If you want to catch the errors, you could do something like:

do {
    let user = try await session.post(DetailModel<UserModel>.self, data: foo, from: url)
    // do something with `user`
} catch let ApiError.serviceError(payload) {
    // present API errors in the UI
} catch let ApiError.httpError(data, response) {
    // handle general web service errors here (e.g. perhaps a nice user-friendly message if response.statusCode == 500 and log the error in Crashlytics or some error handling system)
} catch URLError.notConnectedToInternet {
    // let user know they're not connected to internet
} catch {
    // failsafe for other errors (e.g. parsing problems, etc.)
}