I am trying to nest multiple request together to create the object that I need. Just a short description about the API and what I try to accomplish. I am basically fetching an array of exercises. These exercise however don't have every information that I need so I have to take the id and do another request to get the whole object. From there on I get an array of images which contains the url that I need. Then I need another attribute called variations which consists of an array of exercise ids.
So I basically need to do 4 requests:
Fetch all exercises
fetch exercise by id
From there I take the image urls and fetch the images
Then I want to fetch all the variations using the ids from the array
So my new object should be an array of type:
struct ExerciseDetails: Hashable {
let id: Int
let title: String
let image: [UIImage]
let variations: [ExerciseDetails]
}
Here is what I am trying to do:
URLSession.shared.dataTaskPublisher(for: URL(string: "https://wger.de/api/v2/exercise/?limit=70&offset=40"))
.map { $0.data }
.decode(type: [Exercise].self, decoder: JSONDecoder())
.map { $0.map { $0.id } }
.flatMap(\.publisher)
.flatMap { id in
URLSession.shared.dataTaskPublisher(for: URL(string: "https://wger.de/api/v2/exerciseinfo/\(id)")!)
.map(\.data)
.decode(type: Exercise.self, decoder: JSONDecoder())
.map { $0.images }
.flatMap { image in
URLSession.shared.dataTaskPublisher(for: URL(string: image.imageUrl))
.map { return UIImage(data: $0.data) }
}
}
.map { $0.map { $0.variations } }
.flatMap(\.publisher)
.flatMap { id in
URLSession.shared.dataTaskPublisher(for: URL(string: "https://wger.de/api/v2/exerciseinfo/\(id)")!)
.map(\.data)
.decode(type: Exercise.self, decoder: JSONDecoder())
.map { return $0 }
}
Now I don't know how to map the output to -> [ExerciseInfo]
I think I am on a good way but there is something missing.
Can anybody help?
Related
I'm having difficulty trying to figure out how to loop TMDb results from my publisher 1 and use the value I'm getting from key id for my second publisher. It's not looping through each result, it does get the proper URL inside my func fetchVideos(_ id: Int) but it's not calling each URL from TMDb's results array. I'm not sure if it's because I'm also getting an Array of results from Videos Codable data?
I attempted to use Publishers.MergeMany in my first publisher's flatMap. I'm still definitely at a novice level for combine, any tips would help. I'm trying to get a list of movies, then from the movies get the id key then use that to fetch the movie trailer data for each movie.
print output
https://api.themoviedb.org/3/movie/602223/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/459151/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/385128/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/522478/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/637693/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/529203/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/578701/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/631843/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/645856/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/581644/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/436969/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/568620/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/522931/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/681260/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/630586/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/671/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/618416/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/646207/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/550988/videos?api_key=API_KEY&language=en-US
https://api.themoviedb.org/3/movie/482373/videos?api_key=API_KEY&language=en-US
Videos(id: 459151, results: [themoviedb_demo.Video(id: 3F24D610-4261-4AEB-8906-A9D0E5FE8E4D, iso639_1: "en", iso3166_1: "US", key: "CK6xdYIsaa0", name: "DreamWorks\' The Boss Baby: Family Business | Official Trailer #3 | Peacock", site: "YouTube", size: 1080, type: "Trailer"), themoviedb_demo.Video(id: 417EF49C-B983-4CC3-B435-7A902DECE917, iso639_1: "en", iso3166_1: "US", key: "-rF2j6K5FoM", name: "The Boss Baby 2: Family Business – Official Trailer 2 (Universal Pictures) HD", site: "YouTube", size: 1080, type: "Trailer"), themoviedb_demo.Video(id: C34A3F5F-9429-4267-86F0-5506EF3E8281, iso639_1: "en", iso3166_1: "US", key: "QPzy8Ckza08", name: "THE BOSS BABY: FAMILY BUSINESS | Official Trailer", site: "YouTube", size: 1080, type: "Trailer")])
Codable data
struct Videos: Codable {
let id: Int
let results: [Video]
}
struct Video: Codable {
let id = Int
let key: String
let name: String
enum CodingKeys: String, CodingKey {
case id
case key, name
}
}
struct TMDb: Codable {
let results: [Results]?
}
struct Results: Codable {
let id: Int
let releaseDate, title: String?
let name: String?
enum CodingKeys: String, CodingKey {
case id
case releaseDate = "release_date"
case title
case name
}
}
#Published var movies = TMDb(results: Array(repeating: Results(id: 1, releaseDate: "", title: "", name: "") , count: 5))
#Published var videos = Videos(id: 1, results: Array(repeating: Video(id: 1, key: "", name: "") , count: 5))
func getUpcoming() {
var request = URLRequest(url:URL(string:"https://api.themoviedb.org/3/movie/upcoming?api_key=API_KEY&language=en-US&page=1")!)
request.httpMethod = "GET"
let publisher = URLSession.shared.dataTaskPublisher(for: request)
.map{ $0.data }
.decode(type: TMDb.self, decoder: JSONDecoder())
let publisher2 = publisher
.flatMap{
// loop results from TMDb for id for publisher 2, only one is called
Publishers.MergeMany($0.results!.map { item in
return self.fetchVideos(item.id)
.map { $0 as Videos }
.replaceError(with: nil)
})
}
// Publishers.CombineLatest
Publishers.Zip(publisher, publisher2)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: {_ in
}, receiveValue: { movies, videos in
self.movies = movies
self.videos = videos
}).store(in: &cancellables)
}
func fetchVideos(_ id: Int) -> AnyPublisher<Videos, Error> {
let url = URL(string: "https://api.themoviedb.org/3/movie/\(id)/videos?api_key=API_KEY&language=en-US")!
return URLSession.shared.dataTaskPublisher(for: url)
.mapError { $0 as Error }
.map{ $0.data }
.decode(type: Videos.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
Hi #cole for this operation you don't need either the merge or the zip, because you are not subscribing to two publishers, you are attempting to do an action after your first publisher emitted an event.
For this you only need a map .handleEvents in my opinion.
So lets try to enhance your code, we want to update both movies and videos separately, but we still need videos to be dependent of movies
First we will create the publisher to request the movies:
var request = URLRequest(url:URL(string:"https://api.themoviedb.org/3/movie/upcoming?api_key=API_KEY&language=en-US&page=1")!)
request.httpMethod = "GET"
let publisher = URLSession.shared.dataTaskPublisher(for: request)
.map{ $0.data }
.decode(type: TMDb.self, decoder: JSONDecoder())
Now we enhance this publisher handling by assigning movies:
var request = URLRequest(url:URL(string:"https://api.themoviedb.org/3/movie/upcoming?api_key=API_KEY&language=en-US&page=1")!)
request.httpMethod = "GET"
let publisher = URLSession.shared.dataTaskPublisher(for: request)
.map{ $0.data }
.decode(type: TMDb.self, decoder: JSONDecoder())
.sink(receiveCompletion: { print ($0) },
receiveValue: { self.movies = $0.results })
Now we will add .handleEvent in order to iterate through our movies to create all the publishers which emit videos events and append videos for the videos array:
var request = URLRequest(url:URL(string:"https://api.themoviedb.org/3/movie/upcoming?api_key=API_KEY&language=en-US&page=1")!)
request.httpMethod = "GET"
let publisher = URLSession.shared.dataTaskPublisher(for: request)
.map{ $0.data }
.decode(type: TMDb.self, decoder: JSONDecoder())
.sink(receiveCompletion: { print ($0) },
receiveValue: { self.movies = $0.results })
.handleEvents(receiveSubscription:nil, receiveOutput: { [weak self] movies in guard let self = self else {return}
self.videos = [Videos]()
for movie in movies.results {
self.fetchVideos(movie.id)
}, receiveCompletion:nil, receiveCancel:nil, receiveRequest:nil)
})
.store(in: &cancellables)
Now for the last step lets update the fetchVideos accordingly:
func fetchVideos(_ id: Int) {
let url = URL(string: "https://api.themoviedb.org/3/movie/\(id)/videos?api_key=API_KEY&language=en-US")!
return URLSession.shared.dataTaskPublisher(for: url)
.mapError { $0 as Error }
.map{ $0.data }
.decode(type: Videos.self, decoder: JSONDecoder())
.sink(receiveCompletion: { print ($0) },
receiveValue: { [weak self] videos in guard let self = self else {return}
self.videos.append(videos)
})
.store(in: &cancellables)
}
Solved my own question. I needed to create an array for my #Published variable instead of single item. Then I needed to call .collect() and .append() in my publisher's flatMap which will append to my #Published variable videos.
Assume the following simplified code-snippet
import Foundation
import Combine
public class NetworkFetch {
fileprivate var networkPipelines : Set<AnyCancellable> = []
public func loadDataFor(url : URL)
{
URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: City.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
.sink(receiveCompletion: {_ in print("Finish")},
receiveValue: { v in
print("\(c)\n")
}
)
.store(in: &networkPipelines)
}
}
For each call of loadDataFor a new combine-pipeline is generated and added to the networkPipelines container. This container grows over time.
What is the correct way to remove such an URLSession-pipeline from this container as soon as all data is fetched by the URLSession-pipeline?
One thing you could do is remove your own subscription from inside sink:
But perhaps the better approach is to subscribe to a PassthroughSubject one time and send the requested URL and the callback through it:
private let subject = PassthroughSubject<(URL, (City) -> Void)), Never>()
private var c: Set<AnyCancellable> = []
init() {
subject
.flatMap { (url, callback) in
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: City.self, decoder: JSONDecoder())
.zip(Just(callback).setFailureType(to: Error.self))
}
.sink(receiveCompletion: {_ in print("Finish")},
receiveValue: { (city, callback) in
callback(city)
}
)
.store(in: &c)
}
public func loadDataFor(url : URL, callback: #escaping (City) -> Void) {
subject.send(url, callback)
}
So, a single subscription can handle multiple requests by sending a pair of the requested URL and the callback through the subject.
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.
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.
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()