Using Combine operators to transform Future into Publisher - swift

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

Related

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

How to combine two requests but return generic publisher? Details below

I have a generic function used to send requests to the server.
Now before I send a request I need to check if the session token is expired and update it if needed.
my function looks like this
func upload<T: Decodable>(some parameters here) -> AnyPublisher<T, Error>
I wanted to check and update the token inside that function before calling the main request but in this case, I can not return AnyPublisher<T, Error>
func upload<T: Decodable>(some parameters here) -> AnyPublisher<T, Error> {
if shouldUpdateToken {
let request = // prepare request
let session = // prepare session
return session.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: SomeTokenObject.self, decoder: JSONDecoder())
// here I wanted to save token and continue with
// the previous request
// but using .map, .flatMap, .compactMap will not return needed publisher
// the error message I'll post below
.map {
// update token with $0
// and continue with the main request
}
} else {
return upload() // this will return AnyPublisher<T, Error> so it's ok here
}
}
This error I get when using .flatMap
Cannot convert return expression of type 'Publishers.FlatMap<AnyPublisher<T, Error>, Publishers.Decode<Publishers.MapKeyPath<URLSession.DataTaskPublisher, JSONDecoder.Input>, SomeTokenObject, JSONDecoder>>' (aka 'Publishers.FlatMap<AnyPublisher<T, Error>, Publishers.Decode<Publishers.MapKeyPath<URLSession.DataTaskPublisher, Data>, SomeTokenObject, JSONDecoder>>') to return type 'AnyPublisher<T, Error>'
And similar for .map.
I added another function that was returning AnyPublisher<SomeTokenObject, Error> and thought to use inside shouldUpdateToken like that
func upload<T: Decodable>(some parameters here) -> AnyPublisher<T, Error> {
if shouldUpdateToken {
return refreshToken() // returns AnyPublisher<Void, Error>
// now I need to continue with original request
// and I'd like to use something like
.flatMap { result -> AnyPublisher<T, Error>
upload()
}
// but using .map, .flatMap, .compactMap will not return needed publisher
// the error message I'll post below
} else {
return upload() // this will return AnyPublisher<T, Error> so it's ok here
}
}
for flatMap:
Cannot convert return expression of type 'Publishers.FlatMap<AnyPublisher<T, Error>, AnyPublisher<Void, Error>>' to return type 'AnyPublisher<T, Error>'
for map: Cannot convert return expression of type 'Publishers.Map<AnyPublisher<Void, Error>, AnyPublisher<T, Error>>' to return type 'AnyPublisher<T, Error>'
Maybe I need to change to another approach?
I have a lot of requests all around the app so updating the token in one place is a good idea, but how can it be done?
Here is the refreshToken() function
func refreshToken() -> AnyPublisher<Void, Error> {
let request = ...
let session = ...
return session.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: SomeTokenObject.self, decoder: JSONDecoder())
.map {
// saved new token
}
.eraseToAnyPublisher()
}
You're almost there. You need to eraseToAnyPublisher() to type-erase the returned publisher.
Remember, that an operator like .flatMap (or .map and others) return their own publisher, like the type you see in the error Publishers.FlatMap<AnyPublisher<T, Error>, AnyPublisher<Void, Error>> - you need to type-erase that:
func upload<T: Decodable>(some parameters here) -> AnyPublisher<T, Error> {
if shouldUpdateToken {
return refreshToken() // returns AnyPublisher<Void, Error>
.flatMap { _ -> AnyPublisher<T, Error> in
upload()
}
.eraseToAnyPublisher() // <- type-erase here
} else {
return upload() // actually "return"
}
}
(and make sure that you're not constantly calling the same upload function recursively without any stop condition)

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.

Swift Combine Nested Publishers

I'm trying to compose a nested publisher chain in combine with Swift and I'm stumped. My current code starts throwing errors at the .flatMap line, and I don't know why. I've been trying to get it functional but am having no luck.
What I'm trying to accomplish is to download a TrailerVideoResult and decode it, grab the array of TrailerVideo objects, transform that into an array of YouTube urls, and then for each YouTube URL get the LPLinkMetadata. The final publisher should return an array of LPLinkMetadata objects. Everything works correctly up until the LPLinkMetadata part.
EDIT: I have updated the loadTrailerLinks function. I originally forgot to remove some apart of it that was not relevant to this example.
You will need to import "LinkPresentation". This is an Apple framework for to fetch, provide, and present rich links in your app.
The error "Type of expression is ambiguous without more context" occurs at the very last line (eraseToAnyPublisher).
func loadTrailerLinks() -> AnyPublisher<[LPLinkMetadata], Error>{
return URLSession.shared.dataTaskPublisher(for: URL(string: "Doesn't matter")!)
.tryMap() { element -> Data in
guard let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return element.data
}
.decode(type: TrailerVideoResult.self, decoder: JSONDecoder(.convertFromSnakeCase))
.compactMap{ $0.results }
.map{ trailerVideoArray -> [TrailerVideo] in
let youTubeTrailer = trailerVideoArray.filter({$0.site == "YouTube"})
return youTubeTrailer
}
.map({ youTubeTrailer -> [URL] in
return youTubeTrailer.compactMap{
let urlString = "https://www.youtube.com/watch?v=\($0.key)"
let url = URL(string: urlString)!
return url
}
})
.flatMap{ urls -> [AnyPublisher<LPLinkMetadata, Never>] in
return urls.map{ url -> AnyPublisher <LPLinkMetadata, Never> in
return self.getMetaData(url: url)
.map{ metadata -> LPLinkMetadata in
return metadata
}
.eraseToAnyPublisher()
}
}
.eraseToAnyPublisher()
}
func fetchMetaData(url: URL) -> AnyPublisher <LPLinkMetadata, Never> {
return Deferred {
Future { promise in
LPMetadataProvider().startFetchingMetadata(for: url) { (metadata, error) in
promise(Result.success(metadata!))
}
}
}.eraseToAnyPublisher()
}
struct TrailerVideoResult: Codable {
let results : [TrailerVideo]
}
struct TrailerVideo: Codable {
let key: String
let site: String
}
You can use Publishers.MergeMany and collect() for this:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
func loadTrailerLinks() -> AnyPublisher<[LPLinkMetadata], Error> {
// Download data
URLSession.shared.dataTaskPublisher(for: URL(string: "Doesn't matter")!)
.tryMap() { element -> Data in
guard let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return element.data
}
.decode(type: TrailerVideoResult.self, decoder: decoder)
// Convert the TrailerVideoResult to a MergeMany publisher, which merges the
// [AnyPublisher<LPLinkMetadata, Never>] into a single publisher with output
// type LPLinkMetadata
.flatMap {
Publishers.MergeMany(
$0.results
.filter { $0.site == "YouTube" }
.compactMap { URL(string: "https://www.youtube.com/watch?v=\($0.key)") }
.map(fetchMetaData)
)
// Change the error type from Never to Error
.setFailureType(to: Error.self)
}
// Collect all the LPLinkMetadata and then publish a single result of
// [LPLinkMetadata]
.collect()
.eraseToAnyPublisher()
}
It's a bit tricky to convert an input array of values to array of results, each obtained through a publisher.
If the order isn't important, you can flatMap the input into a Publishers.Sequence publisher, then deal with each value, then .collect them:
.flatMap { urls in
urls.publisher // returns a Publishers.Sequence<URL, Never> publisher
}
.flatMap { url in
self.getMetaData(url: url) // gets metadata publisher per for each url
}
.collect()
(I'm making an assumption that getMetaData returns AnyPublisher<LPLinkMetadata, Never>)
.collect will collect all the emitted values until the upstream completes (but each value might arrive not in the original order)
If you need to keep the order, there's more work. You'd probably need to send the original index, then sort it later.

Preserving Failure Type with Combine's tryMap

I'm using Combine to write a simple web scraper. I'm trying to map the returned data to a string of HTML, throwing ScraperErrors at each possible failure point. At the end, I want to pass this string to my htmlSubject, which is a PassthroughSubject<String, ScraperError>, for further processing.
urlSubscription = URLSession.shared
.dataTaskPublisher(for: url)
.mapError { _ -> ScraperError in // Explicitly stating my failure type is ScraperError
ScraperError.unreachableSite
}
.tryMap { (data, response) -> String in
guard let html = String(data: data, encoding: .utf8) else {
throw ScraperError.readFailed
}
return html
}
.subscribe(htmlSubject) // <-- Not allowed because failure type is now Error
However, I'm finding that .tryMap is erasing my ScraperError to a regular Error, preventing me from chaining my htmlSubject to the end:
Instance method 'subscribe' requires the types 'Error' and
'ScraperError' be equivalent.
Is there an obvious way around this that I'm missing, or am I getting tripped up conceptually? I'm thinking of this chain as building blocks in a large function that maps <(Data, URLResponse), URLError> to <String, ScraperError>.
Any help is appreciated.
Use mapError to convert back to ScraperError after the tryMap:
urlSubscription = URLSession.shared
.dataTaskPublisher(for: url)
.mapError { _ -> ScraperError in // Explicitly stating my failure type is ScraperError
ScraperError.unreachableSite
}
.tryMap { (data, response) -> String in
guard let html = String(data: data, encoding: .utf8) else {
throw ScraperError.readFailed
}
return html
}
.mapError { $0 as! ScraperError }
.subscribe(htmlSubject)
If you don't want to use as!, you'll have to pick some other case to map to:
.mapError { $0 as? ScraperError ?? ScraperError.unknown }
If you don't like that either, you can use flatMap over Result<String, ScraperError>.Publisher:
urlSubscription = URLSession.shared
.dataTaskPublisher(for: url)
.mapError { _ -> ScraperError in // Explicitly stating my failure type is ScraperError
ScraperError.unreachableSite
}
.flatMap { (data, response) -> Result<String, ScraperError>.Publisher in
guard let html = String(data: data, encoding: .utf8) else {
return .init(.readFailed)
}
return .init(html)
}
.subscribe(htmlSubject)
I find the resulting code to be a bit more readable when wrapping Rob's flatMap approach into an extension:
extension Publisher {
func flatMapResult<T>(_ transform: #escaping (Self.Output) -> Result<T, Self.Failure>) -> Publishers.FlatMap<Result<T, Self.Failure>.Publisher, Self> {
self.flatMap { .init(transform($0)) }
}
}
The code example above would then become:
urlSubscription = URLSession.shared
.dataTaskPublisher(for: url)
.mapError { _ -> ScraperError in // Explicitly stating my failure type is ScraperError
ScraperError.unreachableSite
}
.flatMapResult { (data, response) -> Result<String, ScraperError> in
guard let html = String(data: data, encoding: .utf8) else {
return .failure(.readFailed)
}
return .success(html)
}
.subscribe(htmlSubject)