RxSwift: Transforming Operators in Practice

Marin Todorov

This tutorial has been taken from Chapter 8, “Transforming Operators in Practice” of our book RxSwift: Reactive Programming with Swift. The book covers everything from basic Rx theory, all the way up to error handling, UI, architecture, and other advanced Rx concepts. Enjoy!

In the previous tutorial on transforming operators, you learned about the real workhorses behind reactive programming with RxSwift: the map and flatMap dynamic duo.

Of course, those aren’t the only two operators you can use to transform observables, but a program can rarely do without using those two at least few times. The more experience you gain with these two, the better (and shorter) your code will be.

In that previous tutorial (which is also based on a chapter from our RxSwift book) you already got to play around with transforming operators in the safety of a Swift playground. Hopefully you’re ready to take on a real-life project! You’ll get a starter project, which includes as much non-Rx code as possible, and you will complete that project by working through a series of tasks. In the process, you will learn more about map and flatMap, and in which situations you should use them in your code.

Note: In this tutorial, you will need to understand the basics of transforming operators in RxSwift. If you haven’t worked through the previous tutorial, “RxSwift: Transforming Operators”, do that first and then come back to this tutorial.

Without further ado, it’s time to get this show started!

Getting Started With GitFeed

Download the starter project for this tutorial here. The project is up to date with Xcode 8.2.1 and RxSwift 3.2.

I wonder what the latest activity is on the RxSwift repository? In this tutorial, you’ll build a project to tell you this exact thing.

The project you are going to work on in this tutorial displays the activity of a GitHub repository, such as all the latest likes, forks, or comments. To get started with GitFeed, open the starter project for this tutorial, install the required CocoaPods, and open GitFeed.xcworkspace.

The app is a simple navigation controller project and features a single table view controller in which you will display the latest activity fetched from GitHub’s JSON API.

Note: The starter project is set to display the activity of https://github.com/ReactiveX/RxSwift, but if you’d like to change it to any other repository of your choice, feel free.

Run the app and you will see the empty default screen:

There’s nothing too complex going on right now, but you’ll soon have this whole setup ablaze! :]

The project will feature two distinct storylines:

  • The main plot is about reaching out to GitHub’s JSON API, receiving the JSON response, and ultimately converting it into a collection of objects.
  • The subplot is persisting the fetched objects to the disk and displaying them in the table before the “fresh” list of activity events is fetched from the server.

You will see that these two complement each other perfectly — and there are plenty of opportunities to use both map and flatMap to build what’s required.

Fetching Data From the Web

Hopefully you’ve used the URLSession API before and have a general idea of its workflow. In summary: you create a URLRequest containing a web URL and parameters, then send it off to the Internet. After a bit, you receive the server response. In this tutorial, to use URLSession the Rx way, you will simply use a solution boxed with RxCocoa — RxSwift’s companion library.

If you peek into GitFeed’s Podfile, you will notice that you import two different CocoaPods: RxSwift and RxCocoa. What gives?

RxCocoa is a library based on RxSwift, which implements many helpful APIs to aid with developing against RxSwift on Apple’s platforms. In an effort to keep RxSwift itself as close as possible to the common Rx API shared between all implementations such as RxJS, RxJava, and RxPython, all “extra functionality” is separated into RxCocoa.

You will use the default RxCocoa URLSession extension to quickly fetch JSON from GitHub’s API in this tutorial.

Using map to Build a Request

The first task you will undertake is to build a URLRequest you will send off to GitHub’s server. You will follow a reactive approach that might not make sense immediately, but don’t worry — when you re-visit that part of the project later on, you will appreciate it!

Open ActivityController.swift and peek inside. You configure the view controller’s UI in viewDidLoad(), and when you’re finished, you call refresh(). refresh() in turn calls fetchEvents(repo:) and hands over to it the repo name "ReactiveX/RxSwift".

It is in fetchEvents(repo:) where you will add most of your code in this section. To get started, add the following:

let response = Observable.from([repo])

To start building the web request, you begin with a simple string, which is the repository’s full name. The idea to start with a string instead of directly building a URLRequest is to be flexible with the observable’s input.

Next, take the address string and create the fully qualified URL of the activity API endpoint:

.map { urlString -> URL in
  return URL(string: "https://api.github.com/repos/\(urlString)/events")!
}

You use a couple of shortcuts to create the full URL by using a hard-coded string and force unwrapping the result. You end up with the URL to access the latest events’ JSON.

Have you noticed that you specified the closure’s output type? Did you really have to do that? The obvious answer is no; usually you don’t need to explicitly spell out closure input and output types. You can usually leave it to the compiler to figure those out.

However, especially in code where you have several map and/or flatMap operators chained together, you might need to help the compiler out. It will sometimes get lost in figuring out the proper types, but you can aid it by at least spelling out the output types. If you see an error about mismatched or missing types, you can add more type information to your closures and it’ll probably fix the problem.

But enough about compiler woes — back to coding!

Now that you have a URL, you can move on to transforming it into a complete request. Chain to the last operator:

.map { url -> URLRequest in
  return URLRequest(url: url)
}

Easy enough: you use map to transform a URL to a URLRequest by using the provided web address.

Nice work! You’ve chained a couple of map operators to create a more complex transformation:

Now it’s time to bring flatMap into play and fetch some JSON.

Using flatMap to Wait for a Web Response

In the previous tutorial, you learned that flatMap flattens out observable sequences. One of the common applications of flatMap is to add some asynchronicity to a transformation chain. Let’s see how that works.

When you chain several transformations, that work happens synchronously. That is to say, all transformation operators immediately process each other’s output:

When you insert a flatMap in between, you can achieve different effects:

  • You can flatten observables that instantly emit elements and complete, such as the Observable instances you create out of arrays of strings or numbers.
  • You can flatten observables that perform some asynchronous work and effectively “wait” for the observable to complete, and only then let the rest of the chain continue working.

What you need to do in your GitFeed code is something like this:

To do that, append the following code to the operator chain that you have so far:

.flatMap { request -> Observable<(HTTPURLResponse, Data)> in
  return URLSession.shared.rx.response(request: request)
}

You use the RxCocoa response(request:) method on the shared URLSession object. That method returns an Observable<(HTTPURLResponse, Data)>, which completes whenever your app receives the full response from the web server. You will learn more about the RxCocoa rx extensions and how to extend Foundation and UIKit classes yourself in the full RxSwift book.

In the code you just wrote, flatMap allows you to send the web request and receive a response without the need of protocols and delegates. How cool is that? Freely mixing map and flatMap transformations (as above) enables the kind of linear yet asynchronous code you hopefully are starting to appreciate.

Finally, to allow more subscriptions to the result of the web request, chain one last operator. You will use shareReplay(1) to share the observable and keep in a buffer the last emitted event:

.shareReplay(1)

Here you’re using shareReplay(_). Let’s have a look why.

share vs. shareReplay

URLSession.rx.response(request:) sends your request to the server and upon receiving the response emits once a .next event with the returned data, and then completes.

In this situation, if the observable completes and then you subscribe to it again, that will create a new subscription and will fire another identical request to the server.

To prevent situations like this, you use shareReplay(_). This operator keeps a buffer of the last X emitted elements and feeds them to any newly subscribed observer. Therefore if your request has completed and a new observer subscribes to the shared sequence (via shareReplay(_)) it will immediately receive the response from the server that’s being kept in the buffer.

The rule of thumb for using shareReplay(_) is to use it on any sequences you expect to complete – this way you prevent the observable from being re-created. You can also use this if you’d like observers to automatically receive the last X emitted events.

Transforming the Response

It will probably not come as a surprise that along with all the map transforms you did before sending the web request, you will need to do some more after you receive its response.

If you think about it, the URLSession class gives you back a Data object, and this is not an object you can work with right away. You need to transform it to JSON and then to a native object you can safely use in your code.

You’ll now create a subscription to the response observable that converts the response data into objects. Just after that last piece of code you wrote, add the following code on a new line:

response
  .filter { response, _ in
    return 200..<300 ~= response.statusCode
  }

With the filter operator above, you easily discard all error response codes. Your filter will only let through responses having a status code between 200 and 300, which is all the success status codes.

Note 1: Interested in the HTTP response codes list? Check out this article on Wikipedia: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
Note 2: What about this pesky, built-in ~= operator? It’s one of the lesser-known Swift operators, and when used with a range on its left side, checks if the range includes the value on its right side.
Note 3: You’re going to ignore the non-successful status codes, instead of having your observable send an error event. This is a stylistic choice meant to keep the code simple for now.

The data you receive will generally be a JSON-encoded server response containing a list of event objects. As your next task, you will try transforming the response data to an array of dictionaries.

Append another map to the last operator chain:

.map { _, data -> [[String: Any]] in
  guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []),
    let result = jsonObject as? [[String: Any]] else {
      return []
  }
  return result
}

Let’s deconstruct this piece of code:

  • Unlike what you’ve done previously, you discard the response object and take only the response data.
  • You aid the compiler by letting it know you will return an Array<[String: Any]>. This is what an array of JSON objects looks like.
  • You proceed to use JSONSerialization as usual to try to decode the response data and return the result.
  • In case JSONSerialization fails, you return an empty array.

It’s really cool how RxSwift forces you to encapsulate these discrete pieces of work by using operators. And as an added benefit, you are always guaranteed to have the input and output types checked at compile time.

You are almost finished processing the API response. There’s a couple of things left to do before updating the UI. First, you need to filter out any responses that do not contain any event objects. Append to the chain:

.filter { objects in
  return objects.count > 0
}

This will discard any error responses or any responses that do not contain new events since you last checked. You’ll implement fetching only new events later in the tutorial, but you can account for this now and help out your future self. :]

As a final transformation, you will convert the list of JSON objects to a collection of Event objects. Open Event.swift from the starter project and you will see that the class already includes the following:

  • A handy init that takes a JSON object as a parameter
  • A dynamic property named dictionary that exports the event as a JSON object

That’s about everything you need this data entity class to do.

Switch back to ActivityController.swift and append this to the last operator chain inside fetchEvents(repo:):

.map { objects in
  return objects.map(Event.init)
}

This final map transformation takes in a [[String: Any]] parameter and outputs an [Event] result. It does that by calling map on the array itself and transforming its elements one-by-one.

Bam! map just went meta! You’re doing a map inside of a map. :]

I hope you noticed the difference between the two maps. One is a method on an Observable> instance and is acting asynchronously on each emitted element. The second map is a method on an Array; this map synchronously iterates over the array elements and converts them using Event.init.

Finally, it’s time to wrap up this seemingly endless chain of transformations and get to updating the UI. To simplify the code, you will write the UI code in a separate method. For now, simply append this code to the final operator chain:

.subscribe(onNext: { [weak self] newEvents in
  self?.processEvents(newEvents)
})
.addDisposableTo(bag)

Processing the Response

Yes, it’s finally time to perform some side effects. You started with a simple string, built a web request, sent it off to GitHub, and received an answer back. You transformed the response to JSON and then to native Swift objects. Now it’s time to show the user what you’ve been cooking up behind the scenes all this time.

Add this code anywhere in ActivityController’s body:

func processEvents(_ newEvents: [Event]) {

}

In processEvents(_:), you grab the last 50 events from the repository’s event list and store the list into the Variable property events on your view controller. You’ll do that manually for now, since you haven’t yet learned how to directly bind sequences to variables or subjects.

Insert into processEvents():

var updatedEvents = newEvents + events.value
if updatedEvents.count > 50 {
  updatedEvents = Array<Event>(updatedEvents.prefix(upTo: 50))
}

events.value = updatedEvents

You append the newly fetched events to the list in events.value. Additionally, you cap the list to 50 objects. This way you will show only the latest activity in the table view.

Finally, you set the value of events and are ready to update the UI. Since the data source code is already included in ActivityController, you simply reload the table view to display the new data. To the end of the processEvents function, add the following line:

tableView.reloadData()

Run the app, and you should see the latest activity from GitHub. Yours will be different, depending on the current state of the repo in GitHub.

Note: Since you are currently not managing threads, it might take a while for the results to show up in the table. That’s because you end up updating your UI from a background thread. Although this is a bad practice, it still happens to work on the current version of iOS. For now, ignore the delay and you will fix your code later in the tutorial. While waiting, click on the simulator to force a refresh.

Since the code that came with the starter project in viewDidLoad() sets up a table refresh control, you can try to pull down the table. As soon as you pull far enough, the refresh control calls the refresh() method and reloads the events.

If someone forked or liked the repo since the last time you fetched the repo’s events, you will see new cells appear on top.

There is a little issue when you pull down the table view: the refresh control never disappears, even if your app has finished fetching data from the API. To hide it when you’ve finished fetching events, add the following code just below tableView.reloadData():

refreshControl?.endRefreshing()

endRefreshing() will hide the refresh control and reset the table view to its default state.

So far, you should have a good grasp of how and when to use map and flatMap. Throughout the rest of the tutorial, you are going to tie off a few loose ends of the GitFeed project to make it more complete.

Intermission: Handling Erroneous Input

The project as-is is pretty solid, at least in the perfect safety of a Swift Playground or in a step-by-step tutorial like this one. In this short intermission, you are going to look into some real-life server woes that your app might experience.

Switch to Event.swift and have a look at its init. What would happen if one of those objects coming from the server contained a key with a wrong name? Yes you guessed it — your app would crash. The code of the Event class is written somewhat lazily, and it assumes the server will always return valid JSON.

Fix this quickly before moving on. First of all, you need to change the init to a failing initializer. Add a question mark right after the word init like so:

init?(dictionary: AnyDict)

This way, you can return nil from the initializer instead of crashing the app. Find the line fatalError() and replace it with the following:

return nil

As soon as you do that, you will see a few errors pop up in Xcode. The compiler complains that your subscription in ActivityController expects [Event], but receives an [Event?] instead. Since some of the conversions from JSON to an Event object might fail, the result has now changed type to [Event?].

Fear not! This is a perfect opportunity to exercise the difference between map and flatMap one more time. In ActivityController, you are currently converting JSON objects to events via map(Event.init). The shortcoming of this approach is that you can’t filter out nil elements and change the result, so to say, in mid-flight.

What you want to do is filter out any calls to Event.init that returned nil. Luckily, there’s a function that can do this for you: flatMap — specifically, the flatMap on Array (not Observable).

Return to ActivityController.swift and scroll to fetchEvents(repo:). Replace .map(Event.init) with:

objects.flatMap(Event.init)

To recap: any Event.init calls will return nil, and flatMap on those objects will remove any nil values, so you end up with an Observable that returns an array of Event objects (non-optional!). And since you removed the call to fatalError() in the Event.init function, your code is now safer. :]

Persisting Objects to Disk

In this section, you are going to work on the subplot as described in the introduction, where you will persist objects to disk, so when the user opens the app they will instantly see the events you last fetched.

In this example, you are about to persist the events to a .plist file. The amount of objects you are about to store is small, so a .plist file will suffice for now.

First, add a new property to the ActivityController class:

private let eventsFileURL = cachedFileURL("events.plist")

eventsFileURL is the file URL where you will store the events file on your device’s disk. It’s time to implement the cachedFileURL function to grab a URL to where you can read and write files. Add this outside the definition of the view controller class:

func cachedFileURL(_ fileName: String) -> URL {
  return FileManager.default
    .urls(for: .cachesDirectory, in: .allDomainsMask)
    .first!
    .appendingPathComponent(fileName)
}

Add that function anywhere in the controller file. Now, scroll down to processEvents(_:) and append this to the bottom:

let eventsArray = updatedEvents.map{ $0.dictionary } as NSArray
eventsArray.write(to: eventsFileURL, atomically: true)

In this code, you convert updatedEvents to JSON objects (a format also good for saving in a .plist file) and store them in eventsArray, which is an instance of NSArray. Unlike a native Swift array, NSArray features a very simple and straight-forward method to save its contents straight to a file.

To save the array, you call write(to:atomically:) and give it the URL of the file where you want to create the file (or overwrite an existing one).

Cool! processEvents(_:) is the place to perform side effects, so writing the events to disk in that place feels right. But where can you add the code to read the saved events from disk?

Since you need to read the objects back from the file just once, you can do that in viewDidLoad(). This is where you will check if there’s a file with stored events, and if so, load its contents into events.

Scroll up to viewDidLoad() and add this just above the call to refresh():

let eventsArray = (NSArray(contentsOf: eventsFileURL) 
  as? [[String: Any]]) ?? []
events.value = eventsArray.flatMap(Event.init)

This code works similarly to the one you used to save the objects to disk —  but in reverse. You first create an NSArray by using init(contentsOf:), which tries to load list of objects from a plist file and cast it as Array<[String: Any]>.

Then you do a little dance by using flatMap to convert the JSON to Event objects and filter out any failing ones. Even though you persisted them to disk, they all should be valid, but hey — safety first! :]

That should do it. Delete the app from the Simulator, or from your device if you’re working there. Then run the app, wait until it displays the list of events, and then stop it from Xcode. Run the project a second time, and observe how the table view instantly displays the older data while the app fetches the latest events from the web.

Add a Last-Modified Header to the Request

To exercise flatMap and map one more time (yes, they simply are that important), you will optimize the current GitFeed code to request only events it hasn’t fetched before. This way, if nobody has forked or liked the repo you’re tracking, you will receive an empty response from the server and save on network traffic and processing power.

First, add a new property to ActivityController to store the file name of the file in question:

private let modifiedFileURL = cachedFileURL("modified.txt")

This time you don’t need a .plist file, since you essentially need to store a single string like Mon, 30 May 2017 04:30:00 GMT. This is the value of a header named Last-Modified that the server sends alongside the JSON response. You need to send the same header back to the server with your next request. This way, you leave it to the server to figure out which events you last fetched and if there are any new ones since then.

As you did previously for the events list, you will use a Variable to keep track of the Last-Modified header. Add the following new property to ActivityController:

fileprivate let lastModified = Variable<NSString?>(nil)

You will work with an NSString object for the same reasons you used an NSArray before — NSString can easily read and write to disk, thanks to a couple of handy methods.

Scroll to viewDidLoad() and add this code above the call to refresh():

lastModified.value = try? NSString(contentsOf: modifiedFileURL, usedEncoding: nil)

If you’ve previously stored the value of a Last-Modified header to a file, NSString(contentsOf:usedEncoding:) will create an NSString with the text; otherwise, it will return a nil value.

Start with filtering out the error responses. Move to fetchEvents() and create a second subscription to the response observable by appending the following code to the bottom of the method:

response
  .filter {response, _ in
    return 200..<400 ~= response.statusCode
  }

Next you need to:

  • Filter all responses that do not include a Last-Modified header.
  • Grab the value of the header.
  • Convert it to an NSString value.
  • Finally, filter the sequence once more, taking the header value into consideration.

It does sound like a lot of work, and you might be planning on using a filter, map, another filter, or more. In this section, you will use a single flatMap to easily filter the sequence.

You can use flatMap to filter responses that don’t feature a Last-Modified header.

Append this to the operator chain from above:

.flatMap { response, _ -> Observable<NSString> in
  guard let value = response.allHeaderFields["Last-Modified"]  as? NSString else {
    return Observable.never()
  }
  return Observable.just(value)
}

You use guard to check if the response contains an HTTP header by the name of Last-Modified, whose value can be cast to an NSString. If you can make the cast, you return an Observable with a single element; otherwise, you return an Observable, which never emits any elements:

Now that you have the final value of the desired header, you can proceed to update the lastModified property and store the value to the disk. Add the following:

.subscribe(onNext: { [weak self] modifiedHeader in
  guard let strongSelf = self else { return }
  strongSelf.lastModified.value = modifiedHeader
  try? modifiedHeader.write(to: strongSelf.modifiedFileURL, atomically: true,
    encoding: String.Encoding.utf8.rawValue)
})
.addDisposableTo(bag)

In your subscription’s onNext closure, you update lastModified.value with the latest date and then call NSString.write(to:atomically:encoding) to save to disk. In the end, you add the subscription to the view controller’s dispose bag.

To finish working through this part of the app, you need to use the stored header value in your request to GitHub’s API. Scroll toward the top of fetchEvents(repo:) and find the particular map below where you create a URLRequest:

.map { url -> URLRequest in
  return URLRequest(url: url)
}

Replace the above code with this:

.map { [weak self] url -> URLRequest in
  var request = URLRequest(url: url)
  if let modifiedHeader = self?.lastModified.value {
    request.addValue(modifiedHeader as String, 
      forHTTPHeaderField: "Last-Modified")
  }
  return request
}

In this new piece of code, you create a URLRequest just as you did before, but you add an extra condition: if lastModified contains a value, no matter whether it’s loaded from a file or stored after fetching JSON, add that value as a Last-Modified header to the request.

This extra header tells GitHub that you aren’t interested in any events older than the header date. This will not only save you traffic, but responses which don’t return any data won’t count towards your GitHub API usage limit. Everybody wins!

Challenges

Your challenge in this tutorial is to fix the fact that you're updating the UI from a background thread (and by this going against everything that UIKit stands for).

You will learn more about RxSwift schedulers and multi- threading in Chapter 15 of RxSwift: Reactive programming with Swift, “Intro to Schedulers / Threading in Practice.” In this simple tutorial though, you can work through a simple solution to the problem by using the DispatchQueue type.

First of all, make sure you know what thread you’re running on by adding some test print statements. Scroll to fetchEvents(repo:), and inside the first flatMap closure, insert print("main: \(Thread.isMainThread)") so it looks like this:

.flatMap { request -> Observable<(HTTPURLResponse, Data)> in
  print("main: \(Thread.isMainThread)")
  return URLSession.shared.rx.response(request: request)
}

Then add the same print line in the filter immediately below that flatMap. Finally, scroll down and insert the same debug print line anywhere inside processEvents(_:). Run the app and have a look at Xcode’s console. You should be seeing something like this:

main: true
main: false
main: false

UIKit calls viewDidLoad() on the main thread, so when you invoke fetchEvents(repo:) all the code runs on the main thread too. This is also confirmed by the first output line main: true.

But the second and third prints seem to have switched to a background thread. You can skim the code and reassure yourself you never switch threads manually.

Luckily, you only need to touch the current code in two places:

  • In refresh(), switch to a background thread and call fetchEvents(repo:) from there.
  • In processEvents(), make sure you call tableView.reloadData() on the main thread.

That’s it! In case you need some assistance with writing the Grand Central Dispatch code to manage threads, consult the completed project provided with this chapter.

Of course if you want to learn how to do thread switching the Rx way, read more about schedulers and multi-threading in Chapter 15 of RxSwift: Reactive programming with Swift, “Intro to Schedulers / Threading in Practice.”

In this tutorial, you learned about different real-life use cases for map and flatMap — and built a cool project along the way (even though you still need to handle the results on the main thread like the smart programmer you are).

Where to Go From Here?

You can download the final package from this tutorial here.

If you enjoyed what you learned in this tutorial, why not check out the complete RxSwift book, available on our store?

Here’s a taste of what’s in the book:

  • Getting Started: Get an introduction to the reactive programming paradigm, learn the terminology involved and see how to begin using RxSwift in your projects.
  • Event Management: Learn how to handle asynchronous event sequences via two key concepts in Rx — Observables and Observers.
  • Being Selective: See how to work with various events using concepts such as filtering, transforming, combining, and time operators.
  • UI Development: RxSwift makes it easy to work with the UI of your apps using RxCocoa, which provides an integration of both UIKit and Cocoa.
  • Intermediate Topics: Level up your RxSwift knowledge with chapters on reactive networking, multi-threading, and error handling.
  • Advanced Topics: Round out your RxSwift education by learning about MVVM app architecture, scene-based navigation, and exposing data via services.
  • And much, much more!

By the end of this book, you’ll have hands-on experience solving common issues in a reactive paradigm — and you’ll be well on your way to coming up with your own Rx patterns and solutions!

To celebrate the launch of the book, it’s currently on sale for $44.99 - that’s a $10 discount off the cover price! But don’t wait too long, as this deal is only on until Friday, April 7.

If you have any questions or comments on this tutorial, feel free to join the discussion below!

Team

Each tutorial at www.raywenderlich.com is created by a team of dedicated developers so that it meets our high quality standards. The team members who worked on this tutorial are:

Marin Todorov

Part of: Realm and raywenderlich.com. Author of books and apps. More: <a href="http://www.underplot.com">www.underplot.com</a>

Other Items of Interest

Save time.
Learn more with our video courses.

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 20 total!

iOS Team

... 74 total!

Android Team

... 30 total!

Unity Team

... 12 total!

Articles Team

... 14 total!

Resident Authors Team

... 25 total!

Podcast Team

... 7 total!

Recruitment Team

... 9 total!