Home > front end >  How to handle success and error API responses with Swift Generics?
How to handle success and error API responses with Swift Generics?

Time:09-17

I am trying to write a simple function handling authentication POST requests that return JWT tokens.

My LoopBack 4 API returns the token as a JSON packet in the following format:

{ "token": "my.jwt.token" }

In case of an error, the following gets returned instead:

{
  "error": {
    "statusCode": 401,
    "name": "UnauthorizedError",
    "message": "Invalid email or password."
  }
}

As you can see, these types are completely different, they don't have any common properties.

I've defined the following Swift structures to represent them:

// Success
struct Token: Decodable {
  let token: String
}

// Error
struct TokenError: Decodable {
  let error: ApiError
}

struct ApiError: Decodable {
  let statusCode: Int
  let name: String
  let message: String
}

The signature of the authentication request that returns Swift Generics:

@available(iOS 15.0.0, *)
func requestToken<T: Decodable>(_ user: String, _ password: String) async throws -> T

I've been trying to unit test this function but Swift requires me to declare the type of the result up front:

let result: Token = try await requestToken(login, password)

This works perfectly fine for the happy path but if the authentication is unsuccessful, a The data couldn’t be read because it is missing. error gets thrown. I can certainly do-catch it but I haven't been able to cast the result to my TokenError type in order to access its properties.

I have come across a few threads on StackOverflow where the general advice is to represent the success and error types by a common protocol but I've had no luck with that either due to a conflict with the Decodable protocol that the response types already conform to.

So the question is whether it is possible to work with both success and error result variables returned by my requestToken function.

CodePudding user response:

The most natural way, IMO is to throw ApiErrors, so that they can be handled in the same way as other errors. That would look like this:

Mark ApiError as an Error type:

extension ApiError: Error {}

Now you can decode Token directly, and it will throw ApiError if there's an API error, or DecodingError if the data is corrupted. (Note the use of try? in the first decode and try in the else decode. This way it throws if the data can't be decoded at all.)

extension Token: Decodable {
    enum CodingKeys: CodingKey {
        case token
    }
    init(from decoder: Decoder) throws {
        if let container = try? decoder.container(keyedBy: CodingKeys.self),
           let token = try? container.decode(String.self, forKey: .token)
        {
            self.init(token: token)
        } else {
            throw try TokenError(from: decoder).error
        }
    }
}

// Usage if you want to handle ApiErrors specially

do {
    try JSONDecoder().decode(Token.self, from: data)
} catch let error as ApiError {
    // Handle ApiErrors
} catch let error {
    // Handle other errors
}

Another approach is to keep ApiErrors separate from other errors, in which case there are three possible ways requestToken can return. It can return a Token, or it can return a TokenError, or it can throw a parsing error. Throwing an error is handled by throws. Token/TokenError require an "or" type, which is an enum. This could be done with a Result, but that might be a little confusing, since the routine also throws. Instead I'll be explicit.

enum TokenRequestResult {
    case token(Token)
    case error(ApiError)
}

Now you can make this Decodable by first trying to decode it as a Token, and if that fails, try decoding it as a TokenError and extracting the ApiError from that:

extension TokenRequestResult: Decodable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let token = try?
            container.decode(Token.self) {
            self = .token(token)
        } else {
            self = .error(try container.decode(TokenError.self).error)
        }
    }
}

To use this, you just need to switch:

let result = try JSONDecoder().decode(TokenRequestResult.self, from: token)

switch result {
case .token(let token): // use token
case .error(let error): // use error
}
  • Related