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

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()

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()
}

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()

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.

Related

HTTP POST request using Swift Combine

I'm fairly new to Combine declarative API. I'm trying to implement a generic network layer for a SwiftUI application. For all requests that receive data I understand how to structure the data flow.
My problem is that I have some HTTP POST requests that returns no data. Only a HTTP 200 on success. I can't figure out how to create a publisher that will handle a decoding that can fail since there could be not data in the body of the response. Here's what I tried:
func postResource<Resource: Codable>(_ resource: Resource, to endpoint: Endpoint) -> AnyPublisher<Resource?, NetworkError> {
return Just(resource)
.subscribe(on: queue)
.encode(encoder: JSONEncoder())
.mapError { error -> NetworkError in
return NetworkError.encoding(error)
}
.map { data -> URLRequest in
return endpoint.makeRequest(with: data)
}
.tryMap { request -> Resource? in
self.session.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
guard let httpUrlResponse = response as? HTTPURLResponse else { throw NetworkError.unknown }
guard (200 ... 299).contains(httpUrlResponse.statusCode) else { throw NetworkError.error(for: httpUrlResponse.statusCode) }
return data
}
.tryMap { data -> Resource? in
return try? JSONDecoder().decode(Resource.self, from: data)
}
}
.mapError({ error -> NetworkError in
switch error {
case is Swift.DecodingError:
return NetworkError.decoding(error)
case let urlError as URLError:
return .urlError(urlError)
case let error as NetworkError:
return error
default:
return .unknown
}
})
.eraseToAnyPublisher()
}
The compiler is complaining with the following error on tryMap row:
Declared closure result 'Publishers.TryMap<URLSession.DataTaskPublisher, Resource?>' is incompatible with contextual type 'Resource?'
Anyone has an idea?
Thanks!
enum NetworkError: Error {
case encoding(Error)
case error(for: Int)
case decoding(Error)
case urlError(URLError)
case unknown
}
func postResource<Resource: Codable>(_ resource: Resource, to endpoint: Endpoint) -> AnyPublisher<Resource?, NetworkError> {
Just(resource)
.subscribe(on: queue)
.encode(encoder: JSONEncoder())
.mapError { error -> NetworkError in
NetworkError.encoding(error)
}
.map { data -> URLRequest in
endpoint.makeRequest(with: data)
}
.flatMap { request in // the key thing is here you should you use flatMap instead of map
URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
guard let httpUrlResponse = response as? HTTPURLResponse else { throw NetworkError.unknown }
guard 200 ... 299 ~= httpUrlResponse.statusCode else { throw NetworkError.error(for: httpUrlResponse.statusCode) }
return data
}
.tryMap { data -> Resource? in
try? JSONDecoder().decode(Resource.self, from: data)
}
}
.mapError({ error -> NetworkError in
switch error {
case is Swift.DecodingError:
return NetworkError.decoding(error)
case let urlError as URLError:
return .urlError(urlError)
case let error as NetworkError:
return error
default:
return .unknown
}
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

Swift: Extending Combine operators

I'm trying to wrap a tryMap operator along the lines of this article.
extension Publisher where Output == Data {
func decode<T: Decodable>(as type: T.Type = T.self, using decoder: JSONDecoder = .init()) -> Publishers.Decode<Self, T, JSONDecoder> {
decode(type: type, decoder: decoder)
}
}
extension Publisher where Output == URLSession.DataTaskPublisher.Output {
func processData(_: #escaping (Self.Output) throws -> Data) -> Publishers.TryMap<Self, Data> {
tryMap { element -> Data in
guard let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw URLError(.badServerResponse)
}
return element.data
}
}
}
While using it I'm getting a compiler error which I'm struggling with:
return urlSession
.dataTaskPublisher(for: request)
.processData // <- Value of type '(#escaping (URLSession.DataTaskPublisher.Output) throws -> Data) -> Publishers.TryMap<URLSession.DataTaskPublisher, Data>' (aka '(#escaping ((data: Data, response: URLResponse)) throws -> Data) -> Publishers.TryMap<URLSession.DataTaskPublisher, Data>') has no member 'decode'
.decode(as: InstantResponse.self)
.eraseToAnyPublisher()
What would be the correct way of doing it?
Thanks!
First of all, you aren't calling processData - you are missing the parentheses, which would actually execute the function. Second, your processData declaration is incorrect, it should not take a closure as its input argument, since you aren't using that closure anyways.
extension Publisher where Output == URLSession.DataTaskPublisher.Output {
func processData() -> Publishers.TryMap<Self, Data> {
tryMap { element -> Data in
guard let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw URLError(.badServerResponse)
}
return element.data
}
}
}
return urlSession
.dataTaskPublisher(for: request)
.processData() // parentheses necessary here
.decode(as: InstantResponse.self)
.eraseToAnyPublisher()

Chain network calls sequentially in Combine

My goal is to chain multiple (two at this time) network calls with Combine, breaking chain if first call fails.
I have two object types: CategoryEntity and SubcategoryEntity. Every CategoryEntity has a property called subcategoriesIDS.
With first call I need to fetch all subcategories, with second I will fetch all categories and then I will create an array of CategoryEntityViewModel.
CategoryEntityViewModel contains an array of SubcategoryEntityViewModel based on CategoryEntity's subcategoriesIDS.
Just to be clearer:
Fetch subcategories
Fetch categories
Create a SubcategoryEntityViewModel for every fetched subcategory and store somewhere
CategoryEntityViewModel is created for every category fetched. This object will be initialized with a CategoryEntity object and an array of SubcategoryEntityViewModel, found filtering matching ids between subcategoriesIDS and stored SubcategoryEntityViewModel array
My code right now is:
class CategoriesService: Service, ErrorManager {
static let shared = CategoriesService()
internal let decoder = JSONDecoder()
#Published var error: ServerError = .none
private init() {
decoder.dateDecodingStrategyFormatters = [ DateFormatter.yearMonthDay ]
}
func getAllCategories() -> AnyPublisher<[CategoryEntity], ServerError> {
let request = self.createRequest(withUrlString: "\(AppSettings.api_endpoint)/categories/all", forMethod: .get)
return URLSession.shared.dataTaskPublisher(for: request)
.receive(on: DispatchQueue.main)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
switch (response as! HTTPURLResponse).statusCode {
case (401):
throw ServerError.notAuthorized
default:
throw ServerError.unknown
}
}
return data
}
.map { $0 }
.decode(type: NetworkResponse<[CategoryEntity]>.self, decoder: self.decoder)
.map { $0.result}
.mapError { error -> ServerError in self.manageError(error: error)}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
func getAllSubcategories() -> AnyPublisher<[SubcategoryEntity], ServerError> {
let request = self.createRequest(withUrlString: "\(AppSettings.api_endpoint)/subcategories/all", forMethod: .get)
return URLSession.shared.dataTaskPublisher(for: request)
.receive(on: DispatchQueue.main)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
switch (response as! HTTPURLResponse).statusCode {
case (401):
throw ServerError.notAuthorized
default:
throw ServerError.unknown
}
}
return data
}
.map { $0 }
.decode(type: NetworkResponse<[SubcategoryEntity]>.self, decoder: self.decoder)
.map { $0.result }
.mapError { error -> ServerError in self.manageError(error: error)}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
}
These methods are working (sink is called in another class, don't think it is useful so not copied here) but I cannot find the correct way to chain them.
The way to chain async operations with Combine is flatMap. Produce the second publisher inside the map function. Be sure to pass any needed info as a value down into the map function so the second publisher can use it. See How to replicate PromiseKit-style chained async flow using Combine + Swift for a basic example.

How to decode the body of an error in Alamofire 5?

I'm trying to migrate my project from Alamofire 4.9 to 5.3 and I'm having a hard time with error handling. I would like to use Decodable as much as possible, but my API endpoints return one JSON structure when everything goes well, and a different JSON structure when there is an error, the same for all errors across all endpoints. The corresponding Codable in my code is ApiError.
I would like to create a custom response serializer that can give me a Result<T, ApiError> instead of the default Result<T, AFError>. I found this article that seems to explain the general process but the code in there does not compile.
How can I create such a custom ResponseSerializer?
I ended up making it work with the following ResponseSerializer:
struct APIError: Error, Decodable {
let message: String
let code: String
let args: [String]
}
final class TwoDecodableResponseSerializer<T: Decodable>: ResponseSerializer {
lazy var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
private lazy var successSerializer = DecodableResponseSerializer<T>(decoder: decoder)
private lazy var errorSerializer = DecodableResponseSerializer<APIError>(decoder: decoder)
public func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> Result<T, APIError> {
guard error == nil else { return .failure(APIError(message: "Unknown error", code: "unknown", args: [])) }
guard let response = response else { return .failure(APIError(message: "Empty response", code: "empty_response", args: [])) }
do {
if response.statusCode < 200 || response.statusCode >= 300 {
let result = try errorSerializer.serialize(request: request, response: response, data: data, error: nil)
return .failure(result)
} else {
let result = try successSerializer.serialize(request: request, response: response, data: data, error: nil)
return .success(result)
}
} catch(let err) {
return .failure(APIError(message: "Could not serialize body", code: "unserializable_body", args: [String(data: data!, encoding: .utf8)!, err.localizedDescription]))
}
}
}
extension DataRequest {
#discardableResult func responseTwoDecodable<T: Decodable>(queue: DispatchQueue = DispatchQueue.global(qos: .userInitiated), of t: T.Type, completionHandler: #escaping (Result<T, APIError>) -> Void) -> Self {
return response(queue: .main, responseSerializer: TwoDecodableResponseSerializer<T>()) { response in
switch response.result {
case .success(let result):
completionHandler(result)
case .failure(let error):
completionHandler(.failure(APIError(message: "Other error", code: "other", args: [error.localizedDescription])))
}
}
}
}
And with that, I can call my API like so:
AF.request(request).validate().responseTwoDecodable(of: [Item].self) { response in
switch response {
case .success(let items):
completion(.success(items))
case .failure(let error): //error is an APIError
log.error("Error while loading items: \(String(describing: error))")
completion(.failure(.couldNotLoad(underlyingError: error)))
}
}
I simply consider that any status code outside of the 200-299 range corresponds to an error.
ResponseSerializers have a single requirement. Largely you can just copy the existing serializers. For example, if you wanted to parse a CSV (with no response checking):
struct CommaDelimitedSerializer: ResponseSerializer {
func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> [String] {
// Call the existing StringResponseSerializer to get many behaviors automatically.
let string = try StringResponseSerializer().serialize(request: request,
response: response,
data: data,
error: error)
return Array(string.split(separator: ","))
}
}
You can read more in Alamofire's documentation.

Using Combine operators to transform Future into Publisher

I'm using an API (Firebase) that exposes an async interface for most of its method calls. For every request I make through my own API, I want to add a user's token as a header, if such a token exists. I'm trying to make the entire process part of the same pipeline in Combine.
I have the following code:
struct Response<T> {
let value: T
let response: URLResponse
}
...
func makeRequest<T: Decodable>(_ req: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, Error> {
var request = req
return Future<String?, Error> { promise in
if let currentUser = Auth.auth().currentUser {
currentUser.getIDToken() { (idToken, error) in
if error != nil {
promise(.failure(error!))
} else {
promise(.success(idToken))
}
}
} else {
promise(.success(nil))
}
}
.map { idToken -> URLSession.DataTaskPublisher in
if idToken != nil {
request.addValue("Bearer \(idToken!)", forHTTPHeaderField: "Authorization")
}
return URLSession.shared.dataTaskPublisher(for: request)
}
.tryMap { result -> Response<T> in
let value = try decoder.decode(T.self, from: result.data)
return Response(value: value, response: result.response)
}
.receive(on: DispatchQueue.main)
.map(\.value)
.eraseToAnyPublisher()
}
I get an error inside tryMap operator when trying to JSON decode the response data:
Value of type 'URLSession.DataTaskPublisher' has no member 'data'
I'm still wrapping my head around Combine, but can't understand what I'm doing wrong here. Any help would be greatly appreciated!
You are trying to map to another publisher. Most of the time, this is a sign that you need flatMap. If you use map instead, you'll get a publisher that publishes another publisher, which is almost certainly not what you want.
However, flatMap requires that the upstream publisher (the promise) has the same failure type as the publisher that you are mapping to. However, they aren't the same in this case, so you need to call mapError on the data session publisher to change its error type:
return Future<String?, Error> { promise in
promise(.failure(NSError()))
}
// flatMap and notice the change in return type
.flatMap { idToken -> Publishers.MapError<URLSession.DataTaskPublisher, Error> in
if idToken != nil {
request.addValue("Bearer \(idToken!)", forHTTPHeaderField: "Authorization")
}
return URLSession.shared.dataTaskPublisher(for: request)
// change the error type
.mapError { $0 as Error } // "as Error" isn't technically needed. Just for clarity
}
.tryMap { result -> Response<T> in
let value = try decoder.decode(T.self, from: result.data)
return Response(value: value, response: result.response)
}
.receive(on: DispatchQueue.main)
.map(\.value)
.eraseToAnyPublisher()