Swift Combine URLSession retrieving Dataset/Photos using 2x Publishers

Viewed 1098

I have majority of the functionality working and returning exactly what I want. However, I'm having bit of a brain fart when it comes to taking the photos array in the response and assigning them to appropriate employees to be able to render them. Here's what's going on:

  1. There are 4x Codable structs: Response, Company, Employee, and ProfileImages. Response is the main object returned by the API and then decoded into Company having an array of [Employee], each have 3x ProfileImages (small, medium, and large in size)
  2. There's a companyPublisher that fetches the company details along with an array of employees
  3. Then there's a photosPublisher which takes employees array from the previous step and sequences them to be able to retrieve their profileImages.large profile image
  4. At last, I have a Publishers.Zip(companyPublisher, photosPublisher) that sets up the publisher's .sink() to respond with completion once everything requested has been fetched.

Can someone advise what would be the appropriate steps I need to take to be able to assign the correct employee image to the actual employee? I was thinking about setting up an optional UIImage type property inside the Employee codable struct but am still unsure on how I would go about assigning the appropriate Future object to that employee.

Any help would be greatly appreciated. Thanks in advance!

Response.JSON:

{
  "success": true,
  "company": {
    "id": 64,
    "name": "XYZ (Birmingham, AL)",
    "enabled": true
  },
  "employees": [{
    "id": 35,
    "name": "Chad Hughes",
    "email": "chad.hughes@company.com",
    "profileImages": {
      "small": "https://via.placeholder.com/150/09f/fff.png",
      "medium": "https://via.placeholder.com/300/09f/fff.png",
      "large": "https://via.placeholder.com/600/09f/fff.png"
    }
  }, {
    "id": 36,
    "name": "Melissa Martin",
    "email": "melissa.martin@company.com",
    "profileImages": {
      "small": "https://via.placeholder.com/150/F2A/fff.png",
      "medium": "https://via.placeholder.com/300/F2A/fff.png",
      "large": "https://via.placeholder.com/600/F2A/fff.png"
    }
  }]
}

Models.swift (Codable Structs):

struct Response: Codable {
  let success: Bool
  let company: Company
  let employees: [Employee]
  let message: String?
}

struct Company: Codable, Identifiable {
  let id: Int
  let name: String
  let enabled: Bool
}

struct Employee: Codable, Identifiable {
  let id: Int
  let name: String
  let email: String
  let profileImages: ProfileImage
  let profileImageToShow: SomeImage?
}

struct SomeImage: Codable {
  let photo: Data
  init(photo: UIImage) {
    self.photo = photo.pngData()!
  }
}

struct ProfileImage: Codable {
  let small: String
  let medium: String
  let large: String
}

CompanyDetails.swift:

class CompanyDetails: ObservableObject {
  private let baseURL = "https://my.api.com/v1"
  
  @Published var company: Company = Company()
  @Published var employees: [Employee] = []
  
  var subscriptions: Set<AnyCancellable> = []
  
  func getCompanyDetails(company_id: Int) {
    let url = URL(string: "\(baseURL)/company/\(company_id)")
    
    // Company Publisher that retrieves the company details and its employees
    let companyPublisher = URLSession.shared.dataTaskPublisher(for url: url)
      .map(\.data)
      .decode(type: Response.self, decoder: JSONDecoder())
      .eraseToAnyPublisher()
    
    // Photo Publisher that retrieves the employee's profile image in large size
    let photosPublisher = companyPublisher
      .flatMap { response -> AnyPublisher<Employee, Error> in
        Publishers.Sequence(sequence: response.employees)
          .eraseToAnyPublisher()
      }
      .flatMap { employee -> AnyPublisher<UIImage, Error> in
        URLSession.shared.dataTaskPublisher(for url: URL(string: employee.profileImages.large)!)
          .compactMap { UIImage(data: $0.data) }
          .mapError { $0 as Error }
          .eraseToAnyPublisher()
      }
      .collect()
      .eraseToAnyPublisher()
    
    // Zip both Publishers so that all the retrieved data can be .sink()'d at once
    Publishers.Zip(companyPublisher, photosPublisher)
      .receive(on: DispatchQueue.main)
      .sink(
        receiveCompletion: { completion in
          print(completion)
        },
        receiveValue: { company, photos in
          print(company)
          print(photos)
        }
      )
      .store(in: &subscriptions)
  }
}
1 Answers

You're almost there, but you need to "zip" at an inner (nested) level, ie. inside the flatMap:

let employeesPublisher = companyPublisher
   .flatMap { response in
      response.employees.publisher.setFailureType(Error.self)
   }
   .flatMap { employee -> AnyPublisher<(Employee, UIImage), Error> in

      let profileImageUrl = URL(string: employee.profileImages.large)!

      return URLSession.shared.dataTaskPublisher(for url: profileImageUrl)
          .compactMap { UIImage(data: $0.data) }
          .mapError { $0 as Error }

          .map { (employee, $0) } // "zip" here into a tuple

          .eraseToAnyPublisher()

   }
   .collect()

Now you have and array of tuples of employee and the profile image. Similarly, you could also retrieve all the profile images, for example, by using Publishers.Zip3.

EDIT

If you want to update the employee value instead of returning a tuple, you can instead return the updated employee:

// ...
   .map {
      var employeeCopy = employee
      employeeCopy.profileImageToShow = SomeImage(photo: $0)
      return employeeCopy
   }
// ...

What this gives you is an array of employees with profileImageToShow property set, which you can .zip with the original response, as you wanted:

Publishers.Zip(companyPublisher, employeesPublisher)
   .receive(on: DispatchQueue.main)
   .sink { (response, employees) in 
      self.company = response.company
      self.employees = employees
   }
   .store(in: &subscriptions)
Related