handling HTTP status code with URLSession and Combine - swift

I'm trying to handle the responses that arrive from a DataTaskPublisher reading its response status code.
When status code is greater than 299, I'd like to return a ServiceError type as Failure. In every examples that I've seen I've used .mapError and .catch... in this specific case, from a .flatMap, I really don't know how to handle the publisher response to return the Error instead of the TResponse...
return URLSession.DataTaskPublisher(request: urlRequest, session: .shared)
.mapError{error in return ServiceError.request}
.flatMap{ data, response -> AnyPublisher<TResponse, ServiceError> in
if let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode){
return Just(data)
.decode(type: TResponse.self, decoder: JSONDecoder())
.mapError{error in return ServiceError.decode}
.eraseToAnyPublisher()
}else{
//???? HOW TO HANDLE THE ERROR?
}
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()

enum ServiceErrors: Error {
case internalError(_ statusCode: Int)
case serverError(_ statusCode: Int)
}
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
switch (response as! HTTPURLResponse).statusCode {
case (400...499):
throw ServiceErrors.internalError((response as! HTTPURLResponse).statusCode)
default:
throw ServiceErrors.serverError((response as! HTTPURLResponse).statusCode)
}
}
return data
}
.mapError { $0 as! ServiceErrors }
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
NOTE: I relied on this link to make my error handler.

If I correctly understood your goal, you need something like
}else{
return Fail(error: ServiceError.badServiceReply)
.eraseToAnyPublisher()
}
Simple example:
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.google.com")!)
.receive(on: DispatchQueue.main)
.flatMap { _ in
Fail(error: URLError(URLError.unsupportedURL)).eraseToAnyPublisher()
} //for the sake of the demo
.replaceError(with: "An error occurred") //this sets Failure to Never
.assign(to: \.stringValue, on: self)
.store(in: &cancellableBag)
would always assign string "An error occurred" due to remap to Fail publisher

You can use tryMap here:
URLSession.shared.dataTaskPublisher(for: urlRequest)
.mapError { error in return ServiceError.request }
.tryMap { (data, response) throws -> TResponse in
guard let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode else {
throw ServiceError.invalidStatusCode((response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try JSONDecoder().decode(TResponse.self, from: data)
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
This will also simplify your code as you don't need do create an extra publisher.
I also made a few other improvements to the original code:
used URLSession.shared.dataTaskPublisher(for: urlRequest) instead of URLSession.DataTaskPublisher(request: urlRequest, session: .shared)
used the ~= operator to check the status code
used a guard instead of a let as it's more idiomatic

Related

How Do I relogin after getting a 401 back using URLSession and CombineAPI

I'm new to combine and URLSession and I'm trying to find a way to log in after I get a 401 error back. My Set up for the URLSession.
APIErrors:
enum APIError: Error {
case requestFailed
case jsonConversionFailure
case invalidData
case responseUnsuccessful
case jsonParsingFailure
case authorizationFailed
var localizedDescription: String{
switch self{
case .requestFailed: return "Request Failed"
case .invalidData: return "Invalid Data"
case .responseUnsuccessful: return "Response Unsuccessful"
case .jsonParsingFailure: return "JSON Parsing Failure"
case .jsonConversionFailure: return "JSON Conversion Failure"
case .authorizationFailed: return "Failed to login the user."
}
}
}
The CombinAPI itself, I'm trying to catch the 401 either in .catch or .tryCatch, but proving not as easy as I thought.
//1- A Protocol that has an URLSession and a function that returns a publisher.
protocol CombineAPI{
var session: URLSession { get}
// var authenticationFeed: AuthenticationFeed { get }
func execute<T>(_ request: URLRequest, decodingType: T.Type, queue: DispatchQueue, retries: Int) -> AnyPublisher<T, Error> where T: Decodable
//func reauthenticate<T>(_ request: URLRequest, decodingType: T.Type, queue: DispatchQueue, retries: Int) -> AnyPublisher<T, Error> where T: Decodable
}
//2 - Extending CombineAPI so we can have a default implementation.
extension CombineAPI {
func authenticationFeed() -> URLRequest{
return AuthenticationFeed.login(parameters: UserCredentials(userName: UserSettings.sharedInstance.getEmail(), password: UserSettings.sharedInstance.getPassword())).request
}
func execute<T>(_ request: URLRequest,
decodingType: T.Type,
queue: DispatchQueue = .main,
retries: Int = 0) -> AnyPublisher<T, Error> where T: Decodable{
return session.dataTaskPublisher(for: request)
.tryMap {
guard let response = $0.response as? HTTPURLResponse, response.statusCode == 200 else{
let response = $0.response as? HTTPURLResponse
if response?.statusCode == 401{
throw APIError.authorizationFailed
}
print(response!.statusCode)
throw APIError.responseUnsuccessful
}
//Return the data if everything is good
return $0.data
}
.catch(){ _ in
//Try to relogin here or in tryCatch block
}
// .tryCatch { error in
// if Error as? APIError == .authorizationFailed {
// let subcription = self.callFunction().switchToLatest().flatMap { session.dataTaskPublisher(for: request)}.eraseToAnyPublisher()
// return subcription
// }else{
// throw APIError.responseUnsuccessful
// }
// }
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: queue)
.retry(retries)
.eraseToAnyPublisher()
}
func reauthenticate<T>( decodingType: Token.Type, queue: DispatchQueue = .main,retries: Int = 2) -> AnyPublisher<T, Error> where T: Decodable{
return session.dataTaskPublisher(for: self.authenticationFeed())
.tryMap{
guard let response = $0.response as? HTTPURLResponse, response.statusCode == 200 else{
let response = $0.response as? HTTPURLResponse
if response?.statusCode == 401{
throw APIError.authorizationFailed
}
print(response!.statusCode)
throw APIError.responseUnsuccessful
}
//Return the data if everything is good
return $0.data
}
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: queue)
.retry(retries)
.eraseToAnyPublisher()
}
}
This is the feed that will create the URL request itself:
enum UserFeed{
case getUser(userId: Int)
}
extension UserFeed: Endpoint{
var base: String {
return "http://192.168.1.15:8080"
}
var path: String {
switch self{
case .getUser(let userId): return "/api/v1/User/\(userId)"
}
}
var request: URLRequest{
let url = urlComponents.url!
var request = URLRequest(url: url)
switch self{
case .getUser(_):
request.httpMethod = CallType.get.rawValue
request.setValue("*/*", forHTTPHeaderField: "accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(token,forHTTPHeaderField: "tokenheader")
print(token)
return request
}
}
}
Then the client itself where you would create this would be in your viewModel, so you can make the web request for that type of data:
import Foundation
import Combine
final class UserClient: CombineAPI{
var authenticate = PassthroughSubject<Token, Error>()
var session: URLSession
init(configuration: URLSessionConfiguration){
self.session = URLSession(configuration: configuration)
}
convenience init(){
self.init(configuration: .default)
}
func getFeedUser(_ feedKind: UserFeed) -> AnyPublisher<User, Error>{
return execute(feedKind.request, decodingType: User.self, retries: 2)
}
}
I keep trying to make a new request to my authenticationClient, but it returns a different data type, so the ComineAPI doesn't like it. I'm not sure what I should do, otherwise, it works great until I have to authenticate, or get a new token? Any help would be appreciated, thanks.
I Just need it to log in, so I can save the new token to user settings and then continue on the request it left off with, If I can't get a new token, then I return an error to have the user login.
So if I understood correctly you want to be able to catch error 401 and send a different API request from the one you were previously using.
In that case you want to perform the following just as you wrote:
func execute<T>(_ request: URLRequest,
decodingType: T.Type,
queue: DispatchQueue = .main,
retries: Int = 0) -> AnyPublisher<T, APIError> where T: Decodable{
return session.dataTaskPublisher(for: request)
.mapError { error in
if error.statusCode == 401{
return APIError.authorizationFailed
}
return APIError.someOtherGenericError
}
.map{$0}
.decode(type: T.self, decoder: JSONDecoder())
.catch(){ error in
if error == .authorizationFailed {
return session.dataTaskPublisher(for: request) // a new URLRequest that will call for the token generation.
}
}
.receive(on: queue)
.eraseToAnyPublisher()
}
Now you can use the function catch, you need to return a publisher with the same value as you provided, meaning if you defined that you want a "User" object to be returned, then the catch new publisher will have to return the same type of object.
If you want to make it more generic, you can either handle the .catch request for generating token in the .sink closure, or maybe create a function specific for signing in and not using the generic one.
Sorry for giving so little choices, these solutions are the only things that came up from the top of my head.
Hope it helps.

How to decode error response message in Combine?

I'm doing login using SwiftUI and Combine. Could you please give me some idea how can I decode and show json error when user types incorrect email or password? I can only get token.
When I'm doing the same login request with incorrect email or password, server returns me this error message:
{
"code": "[jwt_auth] incorrect_password",
"message": "Incorrect password!",
"data": {
"status": 403
}
}
The problem is that I can't understand how can I decode two different json responses when doing one request in Combine? I can only get token.
Here's model for login request:
struct LoginResponse: Decodable {
let token: String }
struct ErrorResponse: Decodable {
let message: String
}
struct Login: Codable {
let username: String
let password: String
}
static func login(email: String, password: String) -> AnyPublisher<LoginResponse, Error> {
let url = MarketplaceAPI.jwtAuth!
var request = URLRequest(url: url)
let encoder = JSONEncoder()
let login = Login(username: email, password: password)
let jsonData = try? encoder.encode(login)
request.httpBody = jsonData
request.httpMethod = HTTPMethod.POST.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared
.dataTaskPublisher(for: request)
.print()
.receive(on: DispatchQueue.main)
.map(\.data)
.decode(
type: LoginResponse.self,
decoder: JSONDecoder())
.eraseToAnyPublisher()
}
And in viewModel:
MarketplaceAPI.login(email: email, password: password)
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("finished")
case .failure(let error):
print("Failure error:", error.localizedDescription) // This's returning token error
}
},
receiveValue: { value in
print("Token:", value.token)
}
})
.store(in: &subscriptions)
}
I would make ErrorResponse conform to the Error protocol:
struct ErrorResponse: Decodable, Error {
let message: String
}
Then, use tryMap instead of decode (which is sort of a special case of tryMap).
.tryMap({ data -> LoginResponse in
let decoder = JSONDecoder()
guard let loginResponse = try? decoder.decode(LoginResponse.self, from: data) else {
throw try decoder.decode(ErrorResponse.self, from: data)
}
return loginResponse
})
First, try to decode the data as a LoginResponse. Note the use of try? here. This is so that we can check whether this has failed or not. If this fails, we throw an error. The error we throw is the data decoded as an ErrorResponse, or whatever error is thrown during the decoding of that.
In your view model, you can check the error like this:
.sink { completion in
switch completion {
case .failure(let error as ErrorResponse):
// wrong password/username
// you can access error.message here
case .failure(let error):
// some other sort of error:
default:
break
}
} receiveValue: { loginResponse in
...
}
You can make use of tryMap with combine to figure out where the function should return. I'd suggest you take a read of the documentation on it but here is a snippet that should be able to get you moving with it.
Hopefully this is what you mean by the question - I've changed a few things but feel free to take the code as a building block and adapt as needed!
enum LoginError: Error, Equatable {
case noConnection
case invalidResponse
}
static func login(email: String, password: String) -> AnyPublisher<Void, LoginError> {
return URLSession.shared
.dataTaskPublisher(for: request)
.print()
.receive(on: DispatchQueue.main)
.mapError { _ in LoginError.noConnection }
.tryMap { (data, response) in
guard let response = response as? HTTPURLResponse else {
throw LoginError.invalidResponse
}
if response.statusCode == 200 {
return data
} else {
throw LoginError.invalidResponse
}
}
.decode(type: LoginResponse.self, decoder: JSONDecoder())
.tryMap { [unowned self] in
// Update your session with the token here using $0 then return.
// e.g session.token = $0.token
return
}
.mapError { $0 as? LoginError ?? .invalidResponse }
.eraseToAnyPublisher()
}

Swift: How to perform concurrent API calls using Combine

I am attempting to perform concurrent API calls using the Combine framework. The API calls are set up like so:
First, call an API to get a list of Posts
For each post, call another API to get Comments
I would like to use Combine to chain these two calls together and concurrently so that it returns an array of Post objects with each post containing the comments array.
My attempt:
struct Post: Decodable {
let userId: Int
let id: Int
let title: String
let body: String
var comments: [Comment]?
}
struct Comment: Decodable {
let postId: Int
let id: Int
let name: String
let email: String
let body: String
}
class APIClient: ObservableObject {
#Published var posts = [Post]()
var cancellables = Set<AnyCancellable>()
init() {
getPosts()
}
func getPosts() {
let urlString = "https://jsonplaceholder.typicode.com/posts"
guard let url = URL(string: urlString) else {return}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Post].self, decoder: JSONDecoder())
.sink { (completion) in
print("Posts completed: \(completion)")
} receiveValue: { (output) in
//Is there a way to chain getComments such that receiveValue would contain Comments??
output.forEach { (post) in
self.getComments(post: post)
}
}
.store(in: &cancellables)
}
func getComments(post: Post) {
let urlString = "https://jsonplaceholder.typicode.com/posts/\(post.id)/comments"
guard let url = URL(string: urlString) else {
return
}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Comment].self, decoder: JSONDecoder())
.sink { (completion) in
print("Comments completed: \(completion)")
} receiveValue: { (output) in
print("Comment", output)
}
.store(in: &cancellables)
}
}
How do I chain getComments to getPosts so that the output of comments can be received in getPosts? Traditionally using UIKit, I would use DispatchGroup for this task.
Note that I would like to receive just a single Publisher event for posts from the APIClient so that the SwiftUI view is refreshed only once.
Thanks to #matt's post in the comments above, I've adapted the solution in that SO post for my use case above.
Not too sure if it is the best implementation, but it addresses my problem for now.
func getPosts() {
let urlString = "https://jsonplaceholder.typicode.com/posts"
guard let url = URL(string: urlString) else {return}
URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Post].self, decoder: JSONDecoder())
.flatMap({ (posts) -> AnyPublisher<Post, Error> in
//Because we return an array of Post in decode(), we need to convert it into an array of publishers but broadcast as 1 publisher
Publishers.Sequence(sequence: posts).eraseToAnyPublisher()
})
.compactMap({ post in
//Loop over each post and map to a Publisher
self.getComments(post: post)
})
.flatMap {$0} //Receives the first element, ie the Post
.collect() //Consolidates into an array of Posts
.sink(receiveCompletion: { (completion) in
print("Completion:", completion)
}, receiveValue: { (posts) in
self.posts = posts
})
.store(in: &cancellables)
}
func getComments(post: Post) -> AnyPublisher<Post, Error>? {
let urlString = "https://jsonplaceholder.typicode.com/posts/\(post.id)/comments"
guard let url = URL(string: urlString) else {
return nil
}
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main)
.tryMap({ (data, response) -> Data in
guard
let response = response as? HTTPURLResponse,
response.statusCode >= 200 else {
throw URLError(.badServerResponse)
}
return data
})
.decode(type: [Comment].self, decoder: JSONDecoder())
.tryMap { (comments) -> Post in
var newPost = post
newPost.comments = comments
return newPost
}
.eraseToAnyPublisher()
return publisher
}
Essentially, we will need to return a Publisher from the getComments method so that we can loop over each publisher inside getPosts.

How could I throw an Error when response data does not contain an object to decode with Combine?

I have a publisher wrapper struct where I can handle response status code. If the status code is not range in 200..300 it return with an object, otherwise it throws an Error. It works well.
public func anyPublisher<T:Decodable>(type: T.Type) -> AnyPublisher<T, Error> {
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap { output in
guard let httpResponse = output.response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
throw APIError.unknown
}
return output.data
}
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Using:
let sendNewUserPublisher = NetworkPublisher(urlRequest: request).anyPublisher(type: User.self)
cancellationToken = sendNewUserPublisher.sink(receiveCompletion: { completion in
if case let .failure(error) = completion {
NSLog("error: \(error.localizedDescription)")
}
}, receiveValue: { post in
self.post = post
})
As above, I would like to handle the error even if the response data does not contain an object to be decoded.
public func anyPublisher() -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure> {
return URLSession.shared.dataTaskPublisher(for: urlRequest)
// I'd like to handle status code here, and throw an error, if needed
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Thank you in advance for any help you can provide.
I would suggest creating a Publisher that handles the HTTP response status code validation and using that for both of your other publishers - the one that handles an empty request body and the one that decodes the body.
If you need the HTTPURLResponse object even after validating its status code:
extension URLSession.DataTaskPublisher {
/// Publisher that throws an error in case the data task finished with an invalid status code, otherwise it simply returns the body and response of the HTTP request
func httpResponseValidator() -> AnyPublisher<Output, CustomError> {
tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else { throw CustomError.nonHTTPResponse }
let statusCode = httpResponse.statusCode
guard (200..<300).contains(statusCode) else { throw CustomError.incorrectStatusCode(statusCode) }
return (data, httpResponse)
}
.mapError { CustomError.network($0) }
.eraseToAnyPublisher()
}
}
Or if you don't care about any other properties of the response, only that its status code was valid:
func httpResponseValidator() -> AnyPublisher<Data, CustomError> {
tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else { throw CustomError.nonHTTPResponse }
let statusCode = httpResponse.statusCode
guard (200..<300).contains(statusCode) else { throw CustomError.incorrectStatusCode(statusCode) }
return data
}
.mapError { CustomError.network($0) }
.eraseToAnyPublisher()
}
Then you can use this to rewrite both versions of your anyPublisher function:
extension URLSession.DataTaskPublisher {
func anyPublisher<T:Decodable>(type: T.Type) -> AnyPublisher<T, Error> {
httpResponseValidator()
.decode(type: T.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func anyPublisher() -> AnyPublisher<Output, CustomError> {
httpResponseValidator()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

Swift Combine chaining .mapError()

I'm trying to achieve something similar to scenario presented below (create URL, request to server, decode json, error on every step wrapped in custom NetworkError enum):
enum NetworkError: Error {
case badUrl
case noData
case request(underlyingError: Error)
case unableToDecode(underlyingError: Error)
}
//...
func searchRepos(with query: String, success: #escaping (ReposList) -> Void, failure: #escaping (NetworkError) -> Void) {
guard let url = URL(string: searchUrl + query) else {
failure(.badUrl)
return
}
session.dataTask(with: url) { data, response, error in
guard let data = data else {
failure(.noData)
return
}
if let error = error {
failure(.request(underlyingError: error))
return
}
do {
let repos = try JSONDecoder().decode(ReposList.self, from: data)
DispatchQueue.main.async {
success(repos)
}
} catch {
failure(.unableToDecode(underlyingError: error))
}
}.resume()
}
My solution in Combine works:
func searchRepos(with query: String) -> AnyPublisher<ReposList, NetworkError> {
guard let url = URL(string: searchUrl + query) else {
return Fail(error: .badUrl).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) }
.map { $0.data }
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError { $0 as? NetworkError ?? .unableToDecode(underlyingError: $0) }
.subscribe(on: DispatchQueue.global())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
but I really don't like this line
.mapError { $0 as? NetworkError ?? .unableToDecode(underlyingError: $0) }
My questions:
Is there better way to map errors (and replace line above) using chaining in Combine?
Is there any way to include first guard let with Fail(error:) in chain?
I agree with iamtimmo that you don't need .subscribe(on:). I also think this method is the wrong place for .receive(on:), because nothing in the method requires the main thread. If you have code elsewhere that subscribes to this publisher and wants results on the main thread, then that is where you should use the receive(on:) operator. I'm going to omit both .subscribe(on:) and .receive(on:) in this answer.
Anyway, let's address your questions.
Is there better way to map errors (and replace line above) using chaining in Combine?
“Better” is subjective. The problem you're trying to solve here is that you only want to apply that mapError to an error produced by the decode(type:decoder:) operator. You can do that using the flatMap operator to create a mini-pipeline inside the full pipeline:
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) }
.map { $0.data }
.flatMap {
Just($0)
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError { .unableToDecode(underlyingError: $0) } }
.eraseToAnyPublisher()
Is this “better”? Meh.
You could extract the mini-pipeline into a new version of decode:
extension Publisher {
func decode<Item, Coder>(type: Item.Type, decoder: Coder, errorTransform: #escaping (Error) -> Failure) -> Publishers.FlatMap<Publishers.MapError<Publishers.Decode<Just<Self.Output>, Item, Coder>, Self.Failure>, Self> where Item : Decodable, Coder : TopLevelDecoder, Self.Output == Coder.Input {
return flatMap {
Just($0)
.decode(type: type, decoder: decoder)
.mapError { errorTransform($0) }
}
}
}
And then use it like this:
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) }
.map { $0.data }
.decode(
type: ReposList.self,
decoder: JSONDecoder(),
errorTransform: { .unableToDecode(underlyingError: $0) })
.eraseToAnyPublisher()
Is there any way to include first guard let with Fail(error:) in chain?
Yes, but again it's not clear that doing so is better. In this case, the transformation of query into a URL is not asynchronous, so there's little reason to use Combine. But if you really want to do it, here's a way:
return Just(query)
.setFailureType(to: NetworkError.self)
.map { URL(string: searchUrl + $0).map { Result.success($0) } ?? Result.failure(.badUrl) }
.flatMap { $0.publisher }
.flatMap {
session.dataTaskPublisher(for: $0)
.mapError { .request(underlyingError: $0) } }
.map { $0.data }
.decode(
type: ReposList.self,
decoder: JSONDecoder(),
errorTransform: { .unableToDecode(underlyingError: $0) })
.eraseToAnyPublisher()
This is convoluted because Combine doesn't have any operators that can turn a normal output or completion into a typed failure. It has tryMap and similar, but those all produce a Failure type of Error instead of anything more specific.
We can write an operator that turns an empty stream into a specific error:
extension Publisher where Failure == Never {
func replaceEmpty<NewFailure: Error>(withFailure failure: NewFailure) -> Publishers.FlatMap<Result<Self.Output, NewFailure>.Publisher, Publishers.ReplaceEmpty<Publishers.Map<Publishers.SetFailureType<Self, NewFailure>, Result<Self.Output, NewFailure>>>> {
return self
.setFailureType(to: NewFailure.self)
.map { Result<Output, NewFailure>.success($0) }
.replaceEmpty(with: Result<Output, NewFailure>.failure(failure))
.flatMap { $0.publisher }
}
}
Now we can use compactMap instead of map to turn query into a URL, producing an empty stream if we can't create a URL, and use our new operator to replace the empty stream with the .badUrl error:
return Just(query)
.compactMap { URL(string: searchUrl + $0) }
.replaceEmpty(withFailure: .badUrl)
.flatMap {
session.dataTaskPublisher(for: $0)
.mapError { .request(underlyingError: $0) } }
.map { $0.data }
.decode(
type: ReposList.self,
decoder: JSONDecoder(),
errorTransform: { .unableToDecode(underlyingError: $0) })
.eraseToAnyPublisher()
I don't think your approach is unreasonable. A benefit of the first mapError() (at // 1) is that you don't need to know much about the possible errors from the request.
return session.dataTaskPublisher(for: url)
.mapError { NetworkError.request(underlyingError: $0) } // 1
.map { $0.data }
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError { $0 as? NetworkError ?? .unableToDecode(underlyingError: $0) }
.subscribe(on: DispatchQueue.global()) // 2 - not needed
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
I don't think you need the subscribe(on:) at // 2, since URLSession.DataTaskPublisher starts on a background thread already. The subsequent receive(on:) is required.
An alternative approach would be to run through the "happy path" first and map all of the errors later, as in the following. You'll need to understand which errors come from which publishers/operators to correctly map to your NetworkError enum.
return session.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: ReposList.self, decoder: JSONDecoder())
.mapError({ error -> NetworkError in
// map all the errors here
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
To handle your second question, you can use the tryMap() and flatMap() to map your query into a URL and then into a URLSession.DataTaskPublisher instance. I haven't tested this particular code, but a solution would be along these lines.
Just(query)
.tryMap({ query in
guard let url = URL(string: searchUrl + query) else { throw NetworkError.badUrl }
return url
})
.flatMap({ url in
URLSession.shared.dataTaskPublisher(for: url)
.mapError { $0 as Error }
})
.map { $0.data }
//
// ... operators from the previous examples
//
.eraseToPublisher()