Chaining with flatMap - swift

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.

Related

Chaining multiple requests in Combine

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?

Combine - Remove pipeline from container

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.

How do I resolve: Unable to Cast due to 'Unknown Context'?

Scenario:
A function takes a generic parameter (CovidResource) to grab data from a server which returns a data in a particular format depending on the value of the function's parameter, CovidResource.
The intent is to use one (1) function to grab data from assorted endpoints in a format/endpoint.
The function returns a struct AppleSubRegions (in this example, one of any possible data types via generic value) in CovidResource format.
let url = URL(string: "https://disease.sh/v3/covid-19/apple/countries/CANADA")!
countryListViewModel.getList(urlDataModel: CovidResource<AppleSubRegions>(url: url))
struct AppleSubRegions: Codable {
let country, subregion: String
let data: [AppleDatum]
}
import Combine
import UIKit
protocol URLResource {
associatedtype DataModel: Decodable
var url: URL? { get }
}
struct CovidResource<T: Decodable>: URLResource {
typealias DataModel = T
var url = URL(string: "https://disease.sh/v3/covid-19/apple/countries/Canada")
}
// =====================================================================================================
class CountryRegionListModel: ObservableObject {
#Published var countryRegionList: [String] = []
// Data Persistence:
var cancellables: Set<AnyCancellable> = []
// ---------------------------------------------------------------------------
func getList<Resource>(urlDataModel: Resource) where Resource: URLResource {
let remoteDataPublisher = URLSession.shared.dataTaskPublisher(for: urlDataModel.url!)
.map(\.data)
.handleEvents(receiveOutput: { data in
print(String(data: data, encoding: .utf8)!)
})
.receive(on: DispatchQueue.main)
.decode(type: Resource.DataModel.self, decoder: JSONDecoder())
//.print("getList: ")
remoteDataPublisher
.eraseToAnyPublisher()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("Publisher Finished")
case let .failure(anError):
Swift.print("\nReceived error: ", anError)
}
}, receiveValue: { someValue in
print("\n\n ---- Model: \(someValue)")
DataSource.shared.countryName = (someValue as! AppleSubRegions).country
}).store(in: &cancellables)
}
}
You can see the datatype returned is 'AppleSubRegions':
(Notice the 'unknown context...)?
(lldb) po type(of: someValue)
Covid19.TabNavView.(unknown context at $10bb96490).(unknown context at $10bb96504).AppleSubRegions
I can see the country, 'Canada' via the debugger:
---- Model: AppleSubRegions(country: "Canada", subregions: ["Alberta", "Calgary", "Edmonton", "British Columbia", "Vancouver", "Manitoba", "New Brunswick", "Newfoundland and Labrador", "Northwest Territories", "Halifax", "Nova Scotia", "Ontario", "Ottawa", "Toronto", "Prince Edward Island", "Montreal", "Quebec", "Saskatchewan", "All", "Yukon Territory"])
(lldb) po someValue.country
"Canada"
I tried repeating what I did via the debugger in code:
... but as you can see, the DataModel isn't aware of 'country':
Yet I can't access its members.
So I tried to cast 'someValue' to AppleSubRegions type:
Which crashed the compile.
Question: How do I convert the CovidResource into AppleSubRegions proper (Swift data item)?
Suggestions tried:
1. Qualifying closure with
...receiveValue { someValue: AppleSubRegions in
But I got a compiler syntax error:
Adding an additional constraint:
But this causes another compiler error:
This should work if you further constrain the type to AppleSubRegions:
func getList<R: URLResource>(urlDataModel: R) where R.DataModel == AppleSubRegions {
let remoteDataPublisher = Just(json)
.receive(on: DispatchQueue.main)
.decode(type: R.DataModel.self, decoder: JSONDecoder())
remoteDataPublisher
.sink(
receiveCompletion: {...},
receiveValue: { someValue in
print(someValue.country)
}).store(in: &c)
}

Chaining swift combine publishers and receiving each result

In the example below, I’m making a network request to load different movie genres, then using that to load all the movies. The sink only returns the movie results. How could I receive both the genres and movies?
struct Genre: Codable, Identifiable{
let id: Int
let name: String
var movies: [Movie]?
}
struct Movie: Codable, Hashable, Identifiable {
let title: String
let id: Int
let posterPath: String?
let backdropPath : String?
var tagline: String?
}
loadGenres() is AnyPublisher<[Genre], Error>
fetchMoviesIn() is AnyPublisher<[Movie], Error>
class GenresViewModel: ObservableObject{
#Published var genres = [Genre]()
#Published var movies = [Movie]()
var requests = Set<AnyCancellable>()
init(){
NetworkManager.shared.loadGenres()
.flatMap{ genres in
genres.publisher.flatMap{ genre in
NetworkManager.shared.fetchMoviesIn(genre)
}
}
.collect()
.retry(1)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
switch completion{
case .finished:
print("Finished loading all movies in every genre")
case .failure(let error):
print("Error: \(error)")
}
}, receiveValue: { [self] values in
let allMovies = values.joined()
self.movies = allMovies.map{$0}
})
.store(in: &self.requests)
}
}
Depends on how you want to collect genres and movies.
For example, do you want a genre and a list of movies in that genre? The result could be an array of (Genre, [Movies]).
NetworkManager.shared.loadGenres()
.flatMap { genres in
genres.publisher.setFailureType(to: Error.self)
}
.flatMap { genre in
NetworkManager.shared.fetchMoviesIn(genre)
.map { movies in (genre, movies) }
}
.collect()
Or, if you want an array of (Genre, Movie) tuples, then it's a similar approach, but with an additional level of .flatMap to get individual movies
NetworkManager.shared.loadGenres()
.flatMap { genres in
genres.publisher.setFailureType(to: Error.self)
}
.flatMap { genre in
NetworkManager.shared.fetchMoviesIn(genre)
.flatMap { movies in
movies.publisher.setFailureType(to: Error.self)
}
.map { movie in (genre, movie) }
}
.collect()
To answer your comment question, you want to return the updated Genre, you could return that instead of returning a tuple. Bear in mind that since Genre is a struct, you'd need to create a variable copy of the object (the genre available in the flatMap closure is a constant), update the copy, and return that:
NetworkManager.shared.loadGenres()
.flatMap { genres in
genres.publisher.setFailureType(to: Error.self)
}
.flatMap { genre in
NetworkManager.shared.fetchMoviesIn(genre)
.map { movies -> Genre in
var genreCopy = genre
genreCopy.movies = movies
return genreCopy
}
}
.collect()

How do I map a codable JSON struct into Data for decoding a response?

I am having trouble compiling the following code — it fails with “'Cannot convert value of type 'CheckUserNameAvailability' to closure result type 'JSONDecoder.Input' (aka 'Data')”.
How do I map the CheckUserNameAvailability JSON returned by the server to Data?
// This is the JSON returned by the server
// {
// "Username" : "Foo1Bar2",
// "Available" : true
// }
struct CheckUserNameAvailability: Codable {
let Username: String
let Available: Bool
}
enum tvAPI {
static func checkUserName(username: String) -> AnyPublisher<CheckUserNameAvailability, Error> {
// ...
}
}
func testUserName() {
var cancellable: AnyCancellable?
let me = "Foo1Bar2"
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .useDefaultKeys
cancellable = tvAPI.checkUserName(username: me)
.map { $0 } // compilation fails with “Cannot convert value of type 'CheckUserNameAvailability' to closure result type 'JSONDecoder.Input' (aka 'Data')”
.decode(type: [CheckUserNameAvailability].self, decoder: JSONDecoder())
// .print()
.sink(receiveCompletion: {completion in
print(completion)
},
receiveValue: { availability in
print("\(availability)")
})
RunLoop.main.run(until: Date(timeIntervalSinceNow: 10))
withExtendedLifetime(cancellable, {})
}
tvAPI.checkUserName(username:) returns a publisher with output type CheckUserNameAvailability. The decode method on the publisher requires that the publisher's output matches the decoder's input:
func decode<Item, Coder>(type: Item.Type, decoder: Coder) ->
Publishers.Decode<Self, Item, Coder> where
Item : Decodable,
Coder : TopLevelDecoder,
Self.Output == Coder.Input
However, CheckUserNameAvailability is not compatible with JSONDecoder.Input (aka Data).
You don't need to decode the data:
cancellable = tvAPI.checkUserName(username: me)
// .print()
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { availability in
print("\(availability)")
})