Ilias Karim
Ilias Karim

Reputation: 5371

Swift Combine to map URLSession.shared.dataTaskPublisher HTTP response errors

Given an API that for invalid requests, along with 400-range HTTP status code the server returns a JSON payload that includes a readable message. As an example, the server could return { "message": "Not Found" } with a 404 status code for deleted or non-existent content.

Without using publishers, the code would read,

struct APIErrorResponse: Decodable, Error {
  let message: String
}

func request(request: URLRequest) async throws -> Post {
  let (data, response) = try await URLSession.shared.data(for: request)

  let statusCode = (response as! HTTPURLResponse).statusCode
  if 400..<500 ~= statusCode {
    throw try JSONDecoder().decode(APIErrorResponse.self, from: data)
  }

  return try JSONDecoder().decode(Post.self, from: data)
}

Can this be expressed succinctly using only functional code? In other words, how can the following pattern be adapted to decode a different type based on the HTTPURLResponse.statusCode property, to return as an error, or more generally, how can the response property be handled separately from data attribute?

URLSession.shared.dataTaskPublisher(for: request)
  .map(\.data)
  .decode(type: Post.self, decoder: JSONDecoder())
  .eraseToAnyPublisher()

Upvotes: 1

Views: 975

Answers (3)

Rob
Rob

Reputation: 437442

workingdogsupport has provided a good literal translation (+1). And LuLuGaGa has illustrated a nice compositional style (+1).

I might expand upon the latter, though, and recommend pattern matching on the various status codes, e.g. 2xx codes for decoding success, 4xx for graceful web service errors, and a more general .badServerResponse (and includes the diagnostic information so that the developer working on the call point has a chance to figure out what went wrong) for anything else.

E.g., you might have an general extension (which doesn’t use any types particular to the app):

extension Publisher where Output == (data: Data, response: URLResponse) {
    func decode<Success: Decodable, Failure: Decodable & Error>(
        success: Success.Type = Success.self,
        failure: Failure.Type = Failure.self,
        decoder: JSONDecoder = JSONDecoder()
    ) -> AnyPublisher<Success, Error> {
        tryMap { data, response -> Success in
            switch (response as! HTTPURLResponse).statusCode {
            case 200..<300: return try decoder.decode(Success.self, from: data)
            case 400..<500: throw try decoder.decode(Failure.self, from: data)
            default:        throw URLError(.badServerResponse, userInfo: ["data": data, "response": response])
            }
        }
        .eraseToAnyPublisher()
    }
}

Or, because I hate force-unwrapping:

extension Publisher where Output == (data: Data, response: URLResponse) {
    func decode<Success: Decodable, Failure: Decodable & Error>(
        success: Success.Type = Success.self,
        failure: Failure.Type = Failure.self,
        decoder: JSONDecoder = JSONDecoder()
    ) -> AnyPublisher<Success, Error> {
        tryMap { data, response -> Success in
            guard let response = response as? HTTPURLResponse else {
                throw URLError(.badServerResponse, userInfo: ["data": data, "response": response])
            }
            
            switch response.statusCode {
            case 200..<300: return try decoder.decode(Success.self, from: data)
            case 400..<500: throw try decoder.decode(Failure.self, from: data)
            default:        throw URLError(.badServerResponse, userInfo: ["data": data, "response": response])
            }
        }
        .eraseToAnyPublisher()
    }
}

Regardless, I might then have an extension for this app that decodes your particular web service’s specific error struct:

extension Publisher where Output == (data: Data, response: URLResponse) {
    func decode<Success: Decodable>(
        success: Success.Type = Success.self,
        decoder: JSONDecoder = JSONDecoder()
    ) -> AnyPublisher<Success, Error> {
        decode(success: success, failure: APIErrorResponse.self, decoder: decoder)
    }
}

Then the app code can avail itself of the above (and infer the success type):

func postsPublisher(for request: URLRequest) -> AnyPublisher<Post, Error> {
    URLSession.shared.dataTaskPublisher(for: request)
        .decode()
        .eraseToAnyPublisher()
}

Anyway, that results in a succinct call-point, with a reusable extension.

Upvotes: 1

LuLuGaGa
LuLuGaGa

Reputation: 14388

I use a helper method for this:

extension Publisher where Output == (data: Data, response: HTTPURLResponse) {

    func decode<Success, Failure>(
        success: Success.Type,
        failure: Failure.Type,
        decoder: JSONDecoder
    ) -> AnyPublisher<Success, Error> where Success: Decodable, Failure: DecodableError {
        tryMap { data, httpResponse -> Success in
            guard httpResponse.statusCode < 500 else {
                throw MyCustomError.serverUnavailable(status: httpResponse.statusCode)
            }
            guard httpResponse.statusCode < 400 else {
                let error = try decoder.decode(failure, from: data)
                throw error
            }
            let success = try decoder.decode(success, from: data)

            return success
        }
        .eraseToAnyPublisher()
    }
}

typealias DecodableError = Decodable & Error

which allows me to simplify the call sites like so:

URLSession.shared.dataTaskPublisher(for: request)
  .decode(success: Post.self, failure: MyCustomError.self, decoder: JSONDecoder())
  .eraseToAnyPublisher()

Upvotes: 1

you could try something like this approach:

func request(request: URLRequest) -> AnyPublisher<Post, any Error> {
    URLSession.shared.dataTaskPublisher(for: request)
        .tryMap { (output) -> Data in
            let statusCode = (output.response as! HTTPURLResponse).statusCode
            if 400..<500 ~= statusCode {
                throw try JSONDecoder().decode(APIErrorResponse.self, from: output.data)
            }
            return output.data
        }
        .decode(type: Post.self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

Upvotes: 1

Related Questions