Cloud Firestore iOS Codelab

1. Overview

Goals

In this codelab you will build a Firestore-backed restaurant recommendation app on iOS in Swift. You will learn how to:

  1. Read and write data to Firestore from an iOS app
  2. Listen to changes in Firestore data in realtime
  3. Use Firebase Authentication and security rules to secure Firestore data
  4. Write complex Firestore queries

Prerequisites

Before starting this codelab make sure you have installed:

  • Xcode version 14.0 (or higher)
  • CocoaPods 1.12.0 (or higher)

2. Create Firebase console project

Add Firebase to the project

  1. Go to the Firebase console.
  2. Select Create New Project and name your project "Firestore iOS Codelab".

3. Get the Sample Project

Download the Code

Begin by cloning the sample project and running pod update in the project directory:

git clone https://github.com/firebase/friendlyeats-ios
cd friendlyeats-ios
pod update

Open FriendlyEats.xcworkspace in Xcode and run it (Cmd+R). The app should compile correctly and immediately crash on launch, since it's missing a GoogleService-Info.plist file. We'll correct that in the next step.

Set up Firebase

Follow the documentation to create a new Firestore project. Once you've got your project, download your project's GoogleService-Info.plist file from Firebase console and drag it to the root of the Xcode project. Run the project again to make sure the app configures correctly and no longer crashes on launch. After logging in, you should see a blank screen like the example below. If you're unable to log in, make sure you've enabled the Email/Password sign-in method in Firebase console under Authentication.

d5225270159c040b.png

4. Write Data to Firestore

In this section we'll write some data to Firestore so that we can populate the app UI. This can be done manually via the Firebase console, but we'll do it in the app itself to demonstrate a basic Firestore write.

The main model object in our app is a restaurant. Firestore data is split into documents, collections, and subcollections. We will store each restaurant as a document in a top-level collection called restaurants. If you'd like to learn more about the Firestore data model, read about documents and collections in the documentation.

Before we can add data to Firestore, we need to get a reference to the restaurants collection. Add the following to the inner for loop in the RestaurantsTableViewController.didTapPopulateButton(_:) method.

let collection = Firestore.firestore().collection("restaurants")

Now that we have a collection reference we can write some data. Add the following just after the last line of code we added:

let collection = Firestore.firestore().collection("restaurants")

// ====== ADD THIS ======
let restaurant = Restaurant(
  name: name,
  category: category,
  city: city,
  price: price,
  ratingCount: 0,
  averageRating: 0
)

collection.addDocument(data: restaurant.dictionary)

The code above adds a new document to the restaurants collection. The document data comes from a dictionary, which we get from a Restaurant struct.

We're almost there–before we can write documents to Firestore we need to open up Firestore's security rules and describe which parts of our database should be writeable by which users. For now, we'll allow only authenticated users to read and write to the entire database. This is a little too permissive for a production app, but during the app-building process we want something relaxed enough so we won't constantly run into authentication issues while experimenting. At the end of this codelab we'll talk about how to harden your security rules and limit the possibility of unintended reads and writes.

In the Rules tab of the Firebase console add the following rules and then click Publish.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      //
      // WARNING: These rules are insecure! We will replace them with
      // more secure rules later in the codelab
      //
      allow read, write: if request.auth != null;
    }
  }
}

We'll discuss security rules in detail later, but if you're in a hurry, take a look at the security rules documentation.

Run the app and sign in. Then tap the "Populate" button in the upper left, which will create a batch of restaurant documents, although you won't see this in the app yet.

Next, navigate to the Firestore data tab in the Firebase console. You should now see new entries in the restaurants collection:

Screen Shot 2017-07-06 at 12.45.38 PM.png

Congratulations, you have just written data to Firestore from an iOS app! In the next section you'll learn how to retrieve data from Firestore and display it in the app.

5. Display Data from Firestore

In this section you will learn how to retrieve data from Firestore and display it in the app. The two key steps are creating a query and adding a snapshot listener. This listener will be notified of all existing data that matches the query and receive updates in real time.

First, let's construct the query that will serve the default, unfiltered list of restaurants. Take a look at the implementation of RestaurantsTableViewController.baseQuery():

return Firestore.firestore().collection("restaurants").limit(to: 50)

This query retrieves up to 50 restaurants of the top-level collection named "restaurants". Now that we have a query, we need to attach a snapshot listener to load data from Firestore into our app. Add the following code to the RestaurantsTableViewController.observeQuery() method just after the call to stopObserving().

listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
  guard let snapshot = snapshot else {
    print("Error fetching snapshot results: \(error!)")
    return
  }
  let models = snapshot.documents.map { (document) -> Restaurant in
    if let model = Restaurant(dictionary: document.data()) {
      return model
    } else {
      // Don't use fatalError here in a real app.
      fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
    }
  }
  self.restaurants = models
  self.documents = snapshot.documents

  if self.documents.count > 0 {
    self.tableView.backgroundView = nil
  } else {
    self.tableView.backgroundView = self.backgroundView
  }

  self.tableView.reloadData()
}

The code above downloads collection from Firestore and stores it in an array locally. The addSnapshotListener(_:) call adds a snapshot listener to the query that will update the view controller every time the data changes on the server. We get updates automatically and don't have to manually push changes. Remember, this snapshot listener can be invoked at any time as the result of a server-side change so it's important that our app can handle changes.

After mapping our dictionaries to structs (see Restaurant.swift), displaying the data is just a matter of assigning a few view properties. Add the following lines to RestaurantTableViewCell.populate(restaurant:) in RestaurantsTableViewController.swift.

nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)

This populate method is called from the table view data source's tableView(_:cellForRowAtIndexPath:) method, which takes care of mapping the collection of value types from before to the individual table view cells.

Run the app again and verify that the restaurants we saw earlier in console are now visible on the simulator or device. If you completed this section successfully your app is now reading and writing data with Cloud Firestore!

391c0259bf05ac25.png

6. Sorting and Filtering Data

Currently our app displays a list of restaurants, but there is no way for the user to filter based on their needs. In this section you will use Firestore's advanced querying to enable filtering.

Here's an example of a simple query to fetch all Dim Sum restaurants:

let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")

As its name implies, the whereField(_:isEqualTo:) method will make our query download only members of the collection whose fields meet the restrictions we set. In this case, it'll only download restaurants where category is "Dim Sum".

In this app the user can chain multiple filters to create specific queries, like "Pizza in San Francisco" or "Seafood in Los Angeles ordered by Popularity".

Open RestaurantsTableViewController.swift and add the following code block to the middle of query(withCategory:city:price:sortBy:):

if let category = category, !category.isEmpty {
  filtered = filtered.whereField("category", isEqualTo: category)
}

if let city = city, !city.isEmpty {
  filtered = filtered.whereField("city", isEqualTo: city)
}

if let price = price {
  filtered = filtered.whereField("price", isEqualTo: price)
}

if let sortBy = sortBy, !sortBy.isEmpty {
  filtered = filtered.order(by: sortBy)
}

The snippet above adds multiple whereField and order clauses to build a single compound query based on user input. Now our query will only return restaurants that match the user's requirements.

Run your project and verify you can filter by price, city, and category (make sure to type the category and city names exactly). While testing you may see errors in your logs that look like this:

Error fetching snapshot results: Error Domain=io.grpc Code=9 
"The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..." 
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}

This is because Firestore requires indexes for most compound queries. Requiring indexes on queries keeps Firestore fast at scale. Opening the link from the error message will automatically open the index creation UI in the Firebase console with the correct parameters filled in. To learn more about indexes in Firestore, visit the documentation.

7. Writing data in a transaction

In this section, we'll add the ability for users to submit reviews to restaurants. Thus far, all of our writes have been atomic and relatively simple. If any of them errored, we'd likely just prompt the user to retry them or retry them automatically.

In order to add a rating to a restaurant we need to coordinate multiple reads and writes. First the review itself has to be submitted, and then the restaurant's rating count and average rating need to be updated. If one of these fails but not the other, we're left in an inconsistent state where the data in one part of our database doesn't match the data in another.

Fortunately, Firestore provides transaction functionality that lets us perform multiple reads and writes in a single atomic operation, ensuring that our data remains consistent.

Add the following code below all the let declarations in RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:).

let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in

  // Read data from Firestore inside the transaction, so we don't accidentally
  // update using stale client data. Error if we're unable to read here.
  let restaurantSnapshot: DocumentSnapshot
  do {
    try restaurantSnapshot = transaction.getDocument(reference)
  } catch let error as NSError {
    errorPointer?.pointee = error
    return nil
  }

  // Error if the restaurant data in Firestore has somehow changed or is malformed.
  guard let data = restaurantSnapshot.data(),
        let restaurant = Restaurant(dictionary: data) else {

    let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
      NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
    ])
    errorPointer?.pointee = error
    return nil
  }

  // Update the restaurant's rating and rating count and post the new review at the 
  // same time.
  let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
      / Float(restaurant.ratingCount + 1)

  transaction.setData(review.dictionary, forDocument: newReviewReference)
  transaction.updateData([
    "numRatings": restaurant.ratingCount + 1,
    "avgRating": newAverage
  ], forDocument: reference)
  return nil
}) { (object, error) in
  if let error = error {
    print(error)
  } else {
    // Pop the review controller on success
    if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
      self.navigationController?.popViewController(animated: true)
    }
  }
}

Inside of the update block, all the operations we make using the transaction object will be treated as a single atomic update by Firestore. If the update fails on the server, Firestore will automatically retry it a few times. This means that our error condition is most likely a single error occurring repeatedly, for example if the device is completely offline or the user isn't authorized to write to the path they're trying to write to.

8. Security rules

Users of our app should not be able to read and write every piece of data in our database. For example everyone should be able to see a restaurant's ratings, but only an authenticated user should be allowed to post a rating. It's not sufficient to write good code on the client, we need to specify our data security model on the backend to be completely secure. In this section we'll learn how to use Firebase security rules to protect our data.

First, let's take a deeper look at the security rules we wrote at the start of the codelab. Open the Firebase console and navigate to Database > Rules in the Firestore tab.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

The request variable in the rules above is a global variable available in all rules, and the conditional we added ensures that the request is authenticated before allowing users to do anything. This prevents unauthenticated users from using the Firestore API to make unauthorized changes to your data. This is a good start, but we can use Firestore rules to do much more powerful things.

Let's restrict review writes so that the review's user ID must match the ID of the authenticated user. This ensures that users can't impersonate each other and leave fraudulent reviews. Replace your security rules with the following:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null 
                   && request.auth.uid == request.resource.data.userId;
    }
  
    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

The first match statement matches the subcollection named ratings of any document belonging to the restaurants collection. The allow write conditional then prevents any review from being submitted if the review's user ID doesn't match that of the user. The second match statement allows any authenticated user to read and write restaurants to the database.

This works really well for our reviews, as we've used security rules to explicitly state the implicit guarantee we wrote into our app earlier–that users can only write their own reviews. If we were to add an edit or delete function for reviews, this exact same set of rules would also prevent users from modifying or deleting other users' reviews as well. But Firestore rules can also be used in a more granular fashion to limit writes on individual fields within documents rather than the whole documents themselves. We can use this to allow users to update only the ratings, average rating, and the number of ratings for a restaurant, removing the possibility of a malicious user altering a restaurant name or location.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{restaurant} {
      match /ratings/{rating} {
        allow read: if request.auth != null;
        allow write: if request.auth != null 
                     && request.auth.uid == request.resource.data.userId;
      }
    
      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
                    && request.resource.data.city == resource.data.city
                    && request.resource.data.price == resource.data.price
                    && request.resource.data.category == resource.data.category;
    }
  }
}

Here we've split up our write permission into create and update so we can be more specific about which operations should be permitted. Any user can write restaurants to the database, preserving the functionality of the Populate button we made at the start of the codelab, but once a restaurant is written its name, location, price, and category cannot be changed. More specifically, the last rule requires any restaurant update operation to maintain the same name, city, price, and category of the already existing fields in the database.

To learn more about what you can do with security rules, take a look at the documentation.

9. Conclusion

In this codelab, you learned how to basic and advanced reads and writes with Firestore, as well as how to secure data access with security rules. You can find the full solution on the codelab-complete branch.

To learn more about Firestore, visit the following resources: