Codelab para iOS en Cloud Firestore

1. Descripción general

Objetivos

En este codelab, compilarás una app de recomendación de restaurantes respaldada por Firestore en iOS, en Swift. Aprenderás a hacer lo siguiente:

  1. Lee y escribe datos en Firestore desde una app para iOS
  2. Detecta cambios en datos de Firestore en tiempo real
  3. Usa Firebase Authentication y reglas de seguridad para proteger los datos de Firestore
  4. Escribe consultas complejas de Firestore

Requisitos previos

Antes de comenzar este codelab, asegúrate de haber instalado lo siguiente:

  • Xcode versión 14.0 (o posterior)
  • CocoaPods 1.12.0 (o una versión posterior)

2. Crea un proyecto de Firebase console

Agrega Firebase al proyecto

  1. Dirígete a Firebase console.
  2. Selecciona Crear proyecto nuevo y asígnale a tu proyecto el nombre "Codelab para iOS de Firestore".

3. Obtén el proyecto de muestra

Descargue el código

Primero, clona el proyecto de muestra y ejecuta pod update en el directorio del proyecto:

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

Abre FriendlyEats.xcworkspace en Xcode y ejecútalo (Cmd+R). La app debería compilarse correctamente y fallar de inmediato durante el inicio, ya que le falta un archivo GoogleService-Info.plist. Corregiremos eso en el siguiente paso.

Configura Firebase

Sigue la documentación para crear un nuevo proyecto de Firestore. Cuando tengas tu proyecto, descarga el archivo GoogleService-Info.plist correspondiente desde Firebase console y arrástralo a la raíz del proyecto de Xcode. Vuelve a ejecutar el proyecto para asegurarte de que la app se configure correctamente y que ya no falle durante el inicio. Después de acceder, deberías ver una pantalla en blanco como en el siguiente ejemplo. Si no puedes acceder, asegúrate de haber habilitado el método de acceso con correo electrónico y contraseña en Firebase console en Authentication.

d5225270159c040b.png

4. Escribe datos en Firestore

En esta sección, escribiremos algunos datos en Firestore para poder propagar la IU de la app. Esto se puede hacer de forma manual a través de Firebase console, pero lo haremos en la app para demostrar una escritura básica de Firestore.

El objeto principal del modelo de nuestra app es un restaurante. Los datos de Firestore se dividen en documentos, colecciones y subcolecciones. Almacenaremos cada restaurante como un documento en una colección de nivel superior llamada restaurants. Si quieres obtener más información sobre el modelo de datos de Firestore, lee sobre los documentos y las colecciones en la documentación.

Antes de poder agregar datos a Firestore, debemos obtener una referencia a la colección de restaurantes. Agrega lo siguiente al bucle for interno del método RestaurantsTableViewController.didTapPopulateButton(_:).

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

Ahora que tenemos una referencia de colección, podemos escribir algunos datos. Agrega lo siguiente justo después de la última línea de código que agregamos:

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)

El código anterior agrega un documento nuevo a la colección de restaurantes. Los datos del documento provienen de un diccionario, que obtenemos de un struct de restaurante.

Ya casi terminamos. Antes de escribir documentos en Firestore, debemos abrir las reglas de seguridad de Firestore y describir qué partes de nuestra base de datos deberían poder escribir los usuarios. Por ahora, solo permitiremos que los usuarios autenticados lean y escriban en la base de datos completa. Esto es demasiado permisivo para una app de producción, pero durante el proceso de compilación de la app queremos algo lo suficientemente relajado para no tener problemas de autenticación constantemente mientras experimentamos. Al final de este codelab, hablaremos sobre cómo endurecer tus reglas de seguridad y limitar la posibilidad de lecturas y escrituras no deseadas.

En la pestaña Reglas de Firebase console, agrega las siguientes reglas y, luego, haz clic en Publicar.

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;
    }
  }
}

Analizaremos las reglas de seguridad en detalle más adelante, pero si tienes prisa, puedes consultar la documentación de reglas de seguridad.

Ejecuta la app y accede a tu cuenta. Luego, presiona el botón “Propagar” en la esquina superior izquierda, lo que creará un lote de documentos del restaurante, aunque aún no lo verás en la app.

A continuación, navega a la pestaña Datos de Firestore en Firebase console. Ahora deberías ver entradas nuevas en la colección de restaurantes:

Captura de pantalla 06-07-2017 a las 12.45.38 PM.png

¡Felicitaciones! Acabas de escribir datos en Firestore desde una app para iOS. En la siguiente sección, aprenderás a recuperar datos de Firestore y mostrarlos en la app.

5. Muestra datos de Firestore

En esta sección, aprenderás a recuperar datos de Firestore y mostrarlos en la app. Los dos pasos clave son crear una consulta y agregar un objeto de escucha de instantáneas. Este objeto de escucha recibirá una notificación de todos los datos existentes que coincidan con la consulta y recibirá actualizaciones en tiempo real.

Primero, creemos la consulta que entregará la lista predeterminada de restaurantes sin filtros. Observa la implementación de RestaurantsTableViewController.baseQuery():

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

Esta consulta recupera hasta 50 restaurantes de la colección de nivel superior llamada “restaurants”. Ahora que tenemos una consulta, debemos adjuntar un objeto de escucha de instantáneas para cargar datos de Firestore en nuestra app. Agrega el siguiente código al método RestaurantsTableViewController.observeQuery() justo después de la llamada a 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()
}

El código anterior descarga la colección de Firestore y la almacena en un array de manera local. La llamada a addSnapshotListener(_:) agrega un objeto de escucha de instantáneas a la consulta, que actualizará el controlador de vista cada vez que cambien los datos en el servidor. Recibimos actualizaciones automáticamente y no tenemos que aplicar los cambios manualmente. Recuerda que este objeto de escucha de instantáneas se puede invocar en cualquier momento como resultado de un cambio del servidor, por lo que es importante que nuestra app pueda manejar los cambios.

Después de asignar nuestros diccionarios a structs (consulta Restaurant.swift), solo debes asignar algunas propiedades de vista para mostrar los datos. Agrega las siguientes líneas a RestaurantTableViewCell.populate(restaurant:) en 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)

Se llama a este método de propagación desde el método tableView(_:cellForRowAtIndexPath:) de la fuente de datos de la vista de tabla, que se encarga de asignar la colección de tipos de valores anteriores a las celdas individuales de la vista de tabla.

Vuelve a ejecutar la app y verifica que los restaurantes que vimos antes en la consola ahora sean visibles en el simulador o dispositivo. Si completaste correctamente esta sección, tu app ahora lee y escribe datos con Cloud Firestore.

391c0259bf05ac25.png

6. Ordenar y filtrar datos

Actualmente, nuestra app muestra una lista de restaurantes, pero el usuario no tiene forma de filtrar según sus necesidades. En esta sección, usarás las consultas avanzadas de Firestore para habilitar el filtrado.

A continuación, se muestra un ejemplo de una consulta simple para recuperar todos los restaurantes de dim sum:

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

Como su nombre lo indica, el método whereField(_:isEqualTo:) hará que nuestra consulta descargue solo los miembros de la colección cuyos campos cumplan con las restricciones que establecimos. En este caso, solo se descargarán restaurantes en los que category sea "Dim Sum".

En esta app, el usuario puede encadenar varios filtros para crear búsquedas específicas, como "Pizza en San Francisco" o "Mariscos en Los Ángeles ordenados por popularidad".

Abre RestaurantsTableViewController.swift y agrega el siguiente bloque de código al medio de 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)
}

El fragmento anterior agrega varias cláusulas whereField y order para compilar una sola consulta compuesta según las entradas del usuario. Ahora, nuestra consulta solo mostrará los restaurantes que coincidan con los requisitos del usuario.

Ejecuta tu proyecto y verifica que puedas filtrar por precio, ciudad y categoría (asegúrate de escribir la categoría y los nombres de las ciudades exactamente). Mientras realizas las pruebas, es posible que en tus registros veas errores de la siguiente manera:

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=...}

Esto se debe a que Firestore requiere índices para la mayoría de las consultas compuestas. Exigir índices en las consultas mantiene la velocidad de Firestore a gran escala. Si abres el vínculo del mensaje de error, se abrirá automáticamente la IU para crear índices en Firebase console con los parámetros correctos ya completados. Para obtener más información sobre los índices en Firestore, consulta la documentación.

7. Escribe datos en una transacción

En esta sección, agregaremos la capacidad de que los usuarios envíen opiniones a los restaurantes. Hasta ahora, todas nuestras operaciones de escritura han sido atómicas y relativamente simples. Si alguna de ellas tiene un error, es probable que solo le solicitemos al usuario que vuelva a intentarlo o que lo haga automáticamente.

Para agregar una calificación a un restaurante, necesitamos coordinar varias lecturas y escrituras. En primer lugar, se debe enviar la opinión en sí y, luego, se deben actualizar el recuento de calificaciones y la calificación promedio del restaurante. Si uno de estos falla, pero no el otro, nos quedamos en un estado incoherente: los datos de una parte de nuestra base de datos no coinciden con los datos de otra.

Afortunadamente, Firestore proporciona una funcionalidad de transacción que nos permite realizar varias lecturas y escrituras en una sola operación atómica, lo que garantiza que nuestros datos se mantengan coherentes.

Agrega el siguiente código debajo de todas las declaraciones let en 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)
    }
  }
}

Dentro del bloque de actualización, todas las operaciones que realicemos con el objeto de transacción serán tratadas como una sola actualización atómica por parte de Firestore. Si la actualización falla en el servidor, Firestore volverá a intentarlo automáticamente varias veces. Esto significa que la condición de error probablemente sea un solo error que ocurra de forma repetida, por ejemplo, si el dispositivo está completamente sin conexión o el usuario no está autorizado a escribir en la ruta de acceso a la que intenta escribir.

8. Reglas de seguridad

Los usuarios de nuestra app no deberían poder leer ni escribir todos los datos de nuestra base de datos. Por ejemplo, todos deberían poder ver las calificaciones de un restaurante, pero solo un usuario autenticado debería poder publicar una calificación. Escribir un buen código en el cliente no es suficiente, debemos especificar nuestro modelo de seguridad de los datos en el backend para que sea completamente seguro. En esta sección, aprenderemos a usar las reglas de seguridad de Firebase para proteger nuestros datos.

Primero, analicemos más en detalle las reglas de seguridad que escribimos al comienzo del codelab. Abre Firebase console y navega a Base de datos > Reglas en la pestaña Firestore.

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;
    }
  }
}

La variable request de las reglas anteriores es una variable global disponible en todas las reglas. El condicional que agregamos garantiza que se autentique la solicitud antes de permitir que los usuarios realicen acciones. Esto evita que los usuarios no autenticados usen la API de Firestore para realizar cambios no autorizados en tus datos. Este es un buen comienzo, pero podemos usar las reglas de Firestore para realizar acciones mucho más potentes.

Restrinjamos las escrituras de opiniones para que el ID del usuario de la opinión coincida con el ID del usuario autenticado. Esto garantiza que los usuarios no puedan suplantar su identidad y dejar opiniones fraudulentas. Reemplaza las reglas de seguridad por los siguientes:

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;
    }
  }
}

La primera declaración de coincidencia coincide con la subcolección llamada ratings de cualquier documento que pertenece a la colección restaurants. El condicional allow write evita que se envíe cualquier opinión si el ID del usuario de la opinión no coincide con el del usuario. La segunda declaración de coincidencia permite que cualquier usuario autenticado lea y escriba restaurantes en la base de datos.

Esto funciona muy bien para nuestras opiniones, ya que usamos reglas de seguridad para indicar explícitamente la garantía implícita que escribimos antes en nuestra app: los usuarios solo pueden escribir sus propias opiniones. Si agregáramos una función para editar o eliminar las opiniones, este mismo conjunto de reglas también evitaría que los usuarios modifiquen o eliminen las opiniones de otros usuarios. Sin embargo, las reglas de Firestore también se pueden usar de manera más detallada para limitar las operaciones de escritura en campos individuales dentro de los documentos, en lugar de hacerlo en todos los documentos. Podemos usar esto para permitir que los usuarios actualicen solo las calificaciones, la calificación promedio y la cantidad de calificaciones de un restaurante, y así eliminar la posibilidad de que un usuario malicioso modifique el nombre o la ubicación de un restaurante.

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;
    }
  }
}

Aquí dividimos nuestro permiso de escritura en crear y actualizar para poder ser más específicos sobre qué operaciones deberían permitirse. Cualquier usuario puede escribir restaurantes en la base de datos y preservar la funcionalidad del botón Propagar que creamos al comienzo del codelab, pero, una vez que se escribe un restaurante, no se pueden cambiar su nombre, ubicación, precio ni categoría. Más específicamente, la última regla requiere que cualquier operación de actualización de restaurante mantenga el mismo nombre, ciudad, precio y categoría de los campos que ya existen en la base de datos.

Para obtener más información sobre lo que puedes hacer con las reglas de seguridad, consulta la documentación.

9. Conclusión

En este codelab, aprendiste a realizar lecturas y escrituras básicas y avanzadas con Firestore, y a proteger el acceso a los datos con reglas de seguridad. Puedes encontrar la solución completa en la rama codelab-complete.

Para obtener más información sobre Firestore, consulta los siguientes recursos: