Combine try to decode multiple times

Hi,

I recently started reading into and trying Combine and I really like it because I need to think differently from what I am used to when programming. Besides that it enables me to write some very nice and clean code.
However sometimes I am getting into a problem where I want to do a specific action (usually trying to mimic non reactive code) and fail to wrap my head around defining publishers for it.

Here is what I'm trying to do:

  • I make a web request (URLSession.shared.dataTaskPublisher(...)
  • I map the data (.map(\.data))
  • I want to attempt to decode the expected response (.decode(ExpectedResponse.self, from: data)
  • If this fails I want to try and decode something else with the same data.
    Trying to decode ExpectedError.self and if that fails too, then I want to analyze the rest of the response.

So essentially my function should return AnyPublisher<Swift.Result<ExpectedResponse, ExpectedError>, Error> where the result is .success(ExpectedResponse), .failure(ExpectedFailure) and the Error of the publisher should be for anything else, maybe URLSession.DataTaskPublisher.Failure.

Any other suggestions in terms of return type or on how to do that properly in Combine, that'd be helpful.

Thanks!

Perhaps before just mapping to the data, we should ensure that the server sent back status code 200. Let's extend URLResponse with a method that validates the status code:

extension URLResponse {
    func requireHTTPStatusCode(_ code: Int) throws {
        guard
            let response = self as? HTTPURLResponse,
            response.statusCode == 200
            else { throw URLError(.badServerResponse) }
    }
}

And then let's extend Publisher to use it:

extension Publisher where Output == URLSession.DataTaskPublisher.Output {
    func justData(ifStatusCode code: Int) -> Publishers.TryMap<Self, Data> {
        return self
            .tryMap { data, response in
                try response.requireHTTPStatusCode(code)
                return data
        }
    }
}

Now let's mock the types you want to try decoding:

enum API {
    struct ExpectedResponse: Decodable {
        var value: Int
    }

    struct ExpectedError: Error, Decodable {
        var reason: String
    }

    typealias APIResult = Result<API.ExpectedResponse, API.ExpectedError>
}

We could decode the Data to these types by composing Combine operators, but because of the error handling, it gets complicated. We can instead do it by extending API.APIResult with an initializer that handles all the decoding:

extension API.APIResult {
    init(apiData: Data) throws {
        let decoder = JSONDecoder()
        do {
            let response = try decoder.decode(API.ExpectedResponse.self, from: apiData)
            self = .success(response)
        } catch {
            let error = try decoder.decode(API.ExpectedError.self, from: apiData)
            self = .failure(error)
        }
    }
}

Then we can extend Publisher with another extension that goes all the way from URLSession.DataTaskPublisher.Output to API.APIResult:

extension Publisher where Output == URLSession.DataTaskPublisher.Output {
    func decodeAPIResult() -> AnyPublisher<API.APIResult, Error> {
        return self
            .justData(ifStatusCode: 200)
            .tryMap(API.APIResult.init(apiData:))
            .eraseToAnyPublisher()
    }
}

Because you can apply this operator to any Publisher of URLSession.DataTaskPublisher.Output, you can test it without hitting a server. Examples:

Just((
    data: #"{ "value": 123 }"#.data(using: .utf8)!,
    response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse
))
    .decodeAPIResult()
    .sink(
        receiveCompletion: { print("completion:", $0) },
        receiveValue: { print("value:", $0) })

// value: success(__lldb_expr_45.API.ExpectedResponse(value: 123))
// completion: finished

Just((
    data: #"{ "reason": "test failure" }"#.data(using: .utf8)!,
    response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse
))
    .decodeAPIResult()
    .sink(
        receiveCompletion: { print("completion:", $0) },
        receiveValue: { print("value:", $0) })

// value: failure(__lldb_expr_45.API.ExpectedError(reason: "test failure"))
// completion: finished

Just((
    data: #"file not found"#.data(using: .utf8)!,
    response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 404, httpVersion: nil, headerFields: nil)! as URLResponse
))
    .decodeAPIResult()
    .sink(
        receiveCompletion: { print("completion:", $0) },
        receiveValue: { print("value:", $0) })

// completion: failure(Error Domain=NSURLErrorDomain Code=-1011 "(null)")

Just((
    data: #"bogus data"#.data(using: .utf8)!,
    response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse
))
    .decodeAPIResult()
    .sink(
        receiveCompletion: { print("completion:", $0) },
        receiveValue: { print("value:", $0) })

// completion: failure(Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: Optional(Error Domain=NSCocoaErrorDomain Code=3840 "Invalid value around character 0." UserInfo={NSDebugDescription=Invalid value around character 0.}))))
2 Likes

Hi man, I got an error at .eraseToAnyPublisher(). It says: "Type of expression is ambiguous without more context". Do you know how to fix it?

I copied and pasted all of the code from my prior post into a playground in Xcode 12.3 beta 1 and it still works correctly. My guess is you either didn't copy all of the necessary code into your project, or you changed a type signature (or some other part of the code). You need to post more of the code from your project.

Thanks mate, let me re-check