Combine try to decode multiple times

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