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