Search code examples
jsonswiftcombinedecodable

Decode another response if first decoding failed using Combine and Swift


I have the following model:

struct Response: Decodable {
    let message: String
}

struct ErrorResponse: Decodable {
    let errorMessage: String
}

enum APIError: Error {
    case network(code: Int, description: String)
    case decoding(description: String)
    case api(description: String)
}

I'm trying to fetch an url and parse the JSON response using this flow:

func fetch(url: URL) -> AnyPublisher<Response, APIError> {
    URLSession.shared.dataTaskPublisher(for: URLRequest(url: url))

        // #1 URLRequest fails, throw APIError.network
        .mapError { .network(code: $0.code.rawValue, description: $0.localizedDescription) }

        // #2 try to decode data as a `Response`
        .tryMap { JSONDecoder().decode(Response.self, from: $0.data) }

        // #3 if decoding fails, decode as an `ErrorResponse`
        //    and throw `APIError.api(description: errorResponse.errorMessage)`

        // #4 if both fail, throw APIError.decoding
        
        // #5 return
        .eraseToAnyPublisher()
}

I have a problem with #3: how can I decode the original data after the tryMap part?

It seems like the only value I can access there is the error coming from tryMap but I need the original data to decode an ErrorRepsonse.

Note: unfortunately the error response comes with the 200 status and the only way to differentiate them is to decode them.


Solution

  • You can use a flatMap and deal with decoding inside it:

    URLSession.shared.dataTaskPublisher(for: URLRequest(url: url))
       // #1 URLRequest fails, throw APIError.network
       .mapError { 
           APIError.network(code: $0.code.rawValue, description: $0.localizedDescription) 
       }
    
       .flatMap { data -> AnyPublisher<Response, APIError> in
          // #2 try to decode data as a `Response`
          if let response = try? JSONDecoder().decode(Response.self, from: data) {
             return Just(response).setFailureType(to: APIError.self)
                        .eraseToAnyPublisher()
          }
    
          do {
             // #3 if decoding fails, decode as an `ErrorResponse`
             let error = try decoder.decode(ErrorResponse.self, from: data)
                 
             // and throw `APIError.api(description: errorResponse.errorMessage)`
             return Fail(error: APIError.api(description: errorResponse.errorMessage))
                        .eraseToAnyPublisher()
          } catch {
             // #4 if both fail, throw APIError.decoding
             return Fail(error: APIError.decoding(description: error.localizedDescription))
                        .eraseToAnyPublisher()
          }
       }
    

    EDIT

    If you want to do this in a "pure" Combine way, then you'd still want to use a flatMap to have access the original data and to sidestep the original possible network error, and then use tryCatch to deal with the failure path.

    Note that step #4 comes between two parts of step #3:

    URLSession.shared.dataTaskPublisher(for: URLRequest(url: url))
       // #1 URLRequest fails, throw APIError.network
       .mapError { 
           APIError.network(code: $0.code.rawValue, description: $0.localizedDescription) 
       }
       .flatMap { v in
          Just(v)
    
             // #2 try to decode data as a `Response`
             .decode(type: Response.self, decoder: JSONDecoder())
    
             // #3 if decoding fails,
             .tryCatch { _ in
                Just(v)
                   // #3.1 ... decode as an `ErrorResponse`
                   .decode(type: ErrorResponse.self, decoder: JSONDecoder())
                   
                   // #4 if both fail, throw APIError.decoding
                   .mapError { _ in APIError.decoding(description: "error decoding") }
    
                   // #3.2 ... and throw `APIError.api
                   .tryMap { throw APIError.api(description: $0.errorMessage) }
             }
    
             // force unwrap is not terrible here, since you know 
             // that `tryCatch` only ever throws APIError
             .mapError { $0 as! APIError }
       }