Topic 10: Introduction to Firebase

(Credits: notes partly taken from the originals from the previous lecturer Joe Appleton, with amendments and additional information added)

This week we will introduce the world of serverless systems by taking a look at Firebase.

Essential Reading

Firebase

This section is based on Joe Appleton's notes but amendments have been made.

Firebase is a cloud infrastructure service that was acquired by Google in 2015 - it effectively is a sub-product suite of the cloud platform. At the time of writing, Firebase offers a range of web-enabled services, the most popular being:

Firebase, and other infrastructure as a service platform, delegate the need to manage infrastructure away from the developer. This is particularly appealing if you are working in a small team, or, as a solo developer. What's more, for our use, it has a very generous free tier.

Getting Started

Task 1 - Setting Up Firebase

The purpose of this task is simply to set up a firebase account, create a project and register your first application - it is much easier than it sounds.

First of all go to the Firebase homepage. Make sure you're logged in with your Google (GMail) account (create one if you do not have one already).

Firebase Main Page

Then, go to the Firebase Console. This is the main page for managing your Firebase applications:

Firebase Console

Next, create a project and name it whatever you like when prompted:

Specify Firebase project name

Within your project, register a new web application:

Create a Firebase project and register a web application

This will then give you a screen such as that shown below, which will detail how to start your project. In particular, it gives you configuration code, including such things as your API key (keep private!) and project ID. You should copy and paste the sample code into your own code.

Create a Firebase web app

It will look something like this:

const firebaseConfig = {
    apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    authDomain: "web-dev-practical.firebaseapp.com",
    databaseURL: "https://web-dev-practical.firebaseio.com",
    projectId: "web-dev-practical",
    storageBucket: "web-dev-practical.appspot.com",
    messagingSenderId: "1030945380946",
    appId: "1:1030945380946:web:ae1bca104338f617df9d8b",
    measurementId: "G-321SW2SPSG"
};

We then need to create a Firebase app (of type FirebaseApp). We use the initializeApp() function to do this, which can be imported with:

import { initializeApp } from '@firebase/app';

then called with the configuration object above:

const app = initializeApp(firebaseConfig);

Adding Firebase features

We are going to use Firebase Authentication and the Cloud Firestore within our app. If you select your app from the console you will see a screen like this:

Adding Firebase features

Ensure that both Authentication and Cloud Firestore are selected, as shown above.

Firebase Authentication

The first Firebase product we will use is Firebase Authentication. Firebase Authentication gives you a standard secure and flexible cloud authentication service. You can choose to either use standard email/password authentication or third-party providers such as Google, Facebook, GitHub, etc.

You can select an authentication provider as follows:

Choosing a Firebase authentication method

Setting up Firebase in our project is fairly straightforward. First, we need to import our dependencies, this is included in the sample code when you create a Web project.

...
import { initializeApp } from "firebase/app";
...

Next however we need to import what we need from Firebase Auth. Add this import statement:

import { 
    getAuth, 
    createUserWithEmailAndPassword, 
    signInWithEmailAndPassword, 
    onAuthStateChanged, 
    updateProfile 
} from "firebase/auth";

This imports various functions we will use.

To start using Firebase Authentication we use the getAuth() function. This gives us an object of type Auth which is our general authentication manager.

const auth = getAuth();

Signing up a user

We can then signup a user with createUserWithEmailAndPassword():

try {
    const userCredential = await createUserWithEmailAndPassword(auth, email, password);
    // Signed up 
    const user = userCredential.user;
    alert(`ID of new user: ${user.uid}`);
} catch(error)  {
    alert(`ERROR: Code: ${error.code}, full message: ${error.message}`);
}

This function takes our original Auth object, an email and a password as parameters and returns a promise which will resolve once the user has been added to Firebase. It resolves with an object of type UserCredential representing the user credentials. This contains an object of type User (the user property) which itself contains a uid property containing a unique ID for this user.

If there are any errors with the signup process (these can include invalid email address or insufficiently complex password) the promise will reject and the catch block will run.

Logging in

Logging in has very similar logic to signing up:

try {
    const userCredential = await signInWithEmailAndPassword(auth, email, password);
    // We can get the user object again if we need it
    const user = userCredential.user;
    alert(`Logged in successfully! Email=${user.email}`);
} catch(error)  {
    alert(`ERROR: Code: ${error.code}, full message: ${error.message}`);
}

Logging out

Logging out is very easy, just use the signOut() method of the Auth object:

try {
    await auth.signOut();
} catch(error) {
    alert(`${error.code} ${error.message}`);
}

Adding a display name

To add a display name (real name, e.g. Alan Smith) you can update the user profile. This can be done immediately after the code to signup a user:

const profile = await updateProfile(user, {
    displayName: name
});

Detecting changes in authentication state

Firebase Authentication allows us to easily detect changes in authentication state (i.e. logged out to logged in, and vice-versa). To do this, we specify a callback function which runs each time authentication state changes with onAuthStateChanged():

onAuthStateChanged(auth, user => {
    if(user) {
        document.getElementById('btnLogout').style.display = 'inline';
        document.getElementById('btnLogin').style.display = 'none';
    } else {
        document.getElementById('btnLogout').style.display = 'none';
        document.getElementById('btnLogin').style.display = 'inline';
    }
});

The callback receives the original Auth object and a user object representing the currently logged-in user. This will be null if the user is logged out. In this example, we include logic to show or hide the login and logout buttons appropriately. If the user has logged in we show the logout button and hide the loginbn button, while if they have logged out we do the reverse.

Detecting if a user is logged in at any time

We may wish to detect, at any time (not just when the user logs in or logs out), whether the user is currently logged in. We can use the currentUser property of the Auth object (type User) to do this. It will be null if the user is not logged in. For example:

document.getElementById('btnAccessProtectedResource').addEventListener("click", e => {
    if(auth.currentUser) {
        alert(`${auth.currentUser.displayName || auth.currentUser.email}, here is your confidential information ....`);
    } else {
        alert("Cannot access - please login.");
    }
});

This example also shows how you can get information about the logged-in user: the currentUser object includes email and displayName properties.

Cloud Firestore

We will also start looking at Cloud Firestore - Firebase's cloud database solution. This week we will look at basic search and insert and next week we will look at more advanced queries such as update and delete.

Cloud Firestore is an example of a NoSQL database. NoSQL databases are databases which use methods other than SQL to access. A common type of NoSQL database is a document database, and Firestore is one such example. Document databases consist of:

If you have done MongoDB before, you might recognise this arrangement. See the data model documentation for more detail.

Essential reading

Cloud Firestore on Firebase docs

Accessing the Cloud Firestore from the Firebase Console

Once you've added the Cloud Firestore to the Firebase Console you can access the data within it by clicking Firestore Database from the sidebar. The diagram below shows this. It shows :

You can manage data by adding and removing it via this web interface, but more normally you would do it from a client app.

Cloud Firestore

You also need to set the rules for accessing the Firestore database. One of the powerful features of Firebase is that you can link the Firestore to Firebase Auth, and automatically restrict database access to authenticated users. You can control this in the Rules tab of the Cloud Firestore:

Cloud Firestore Rules

Note the rules include allow read and allow write specifiers, to control access to read and write operations respectively. By default both are set to false for security reasons. However the settings above are good for a basic web app: reading data does not require authentication, while writing data requires the user to be authenticated (note that request.auth is the Auth object from Firebase Auth).

We can setup more detailed authentication control. See the Firebase documentation for more detail on rules.

Imports

The imports below will cover everything we will do. Note how we have to import from firebase/firestore rather than firebase/auth.

import { 
    getFirestore, 
    collection, 
    addDoc, 
    getDocs, 
    query, 
    where, 
    doc,     
    writeBatch, 
    getDoc 
} from "firebase/firestore";

Create your Firestore object

const db = getFirestore(app);

To search for data, you use the getDocs() function. This returns a QuerySnapshot: an object containing a series of DocumentSnapshots returned by the query.

try {
    const querySnapshot = await getDocs(collection(db, "songs"));
    let html = "";
    if(querySnapshot.empty) {
        alert("No results!");
    } else {
        const html =  querySnapshot.docs.map ( doc => {
                const data = doc.data();
                return `${doc.id} : ${data.title} ${data.artist}`;
            } ).join("<br />");
        document.getElementById("...").innerHTML = html;
    }
} catch(e) {
    alert(e);
}

Note how we can loop through the documents in the QuerySnapshot using forEach. Each time the forEach arrow function is called, the current document (doc) will be supplied as a parameter. We can then obtain the data associated with the current document using doc.data().

Note how the id belongs to the document, not the data.

This example returns all data. In many cases however, we want to obtain only certain data, i.e. do a query. For example:

try {
    const poiType = document.getElementById("poiType").value;
        // to get all:
    const q = query(collection(db,"pointsofinterest"), where("type", "==", poiType));
    const querySnapshot = await getDocs(q);

    // We can then process the querySnapshot as before...
} catch(e) {
    alert(e);
}

Note here how we build a query, using the collection, a where condition (where is actually a function, which takes a field, an operator and a value the field should be equal to for the query to return results.

We then pass the query into getDocs() and continue as before.

Inserting data

To insert data we use the addDoc() function, and specify, as arguments, the collection we want to add the data to, and a JavaScript object containing the data. This will create a document containing this data and return the unique ID (key) allocated to this document.

try {
    const docRef = await addDoc(collection(db,"pointsofinterest"), { 
        name: nameFromForm, 
        type: typeFromForm,
        location: locationFromForm 
} );
    alert(`Record added successfully. ID=${docRef.id}`);
} catch(e) {
    alert(e);
}

The return value of addDoc() is a DocumentReference object: a reference to a specific document.

Update and delete operations in Firestore

Above we looked at how to create and retrieve data in Firestore. This week we will look at update and delete operations, which are a little more complex in their implementation. There are two general approaches to performing updates and deletes:

Transactions

Here is an example of the use of a Firestore transaction.

async function likePoi(id) {
    const docRef = doc(db, "pointsofinterest", id);

    try {
        await runTransaction(db, async(transaction) => {
            const doc = await transaction.get(docRef);
            if(!doc.exists()) {
                throw(`POI with ID ${id} does not exist!`);
            }

            const newLikes = (doc.data().likes || 0) + 1;

            transaction.update(docRef, { likes: newLikes } );
            document.getElementById(`likes${id}`).innerHTML = newLikes;
        });
    } catch(e) {
        alert(e);
    }
}

Note that:

const docRef = doc(db, "pointsofinterest", id);
const doc = await transaction.get(docRef);

and check it actually exists.

Batched writes

Batched writes are an alternative to transactions in cases where the value of the updated data does not depend on the existing value. Here is an example:

async function updateStudent(id, newName, newCourse, newMark) {

    try {
        const docRef = doc(db, "students", id);
        const docum = await getDoc(docRef);
        if(docum.data()) {
            const batch = writeBatch(db);
            batch.update(docRef, {
                name: newName,
                course: newCourse,
                mark: newMark
            } );
            await batch.commit();
            alert(`Record updated successfully.`);
        } else {
            alert('That document does not exist.');
        }
    } catch(e) {
        alert(e);
    }
}

We again obtain a document reference and then a document. We check that the document has data in it (which it will not if the ID does not exist) by checking that the data()method of our document returns a non-null value.

Then we start a batched update with the writeBatch() function. You can put smultiple updates or deletes inside one single batched update, and then commit it at the end with commit(). Here, we are again performing an update() to update selected properties of the student document. We could equally well use set() to completely replace the document, or delete() to delete it.

Exercise 10.1

Clone this repository:

https://github.com/nwcourses/FirebaseStarter

This is a starter for a Vite-based Firebase project. It contains a package.json setup with Vite and Firebase as dependencies. So to install:

npm install

and to start the server:

npm run dev 

There is an index.html with various forms set up.

Firebase Auth

  1. Ensure you have created a Firebase project, enabled Firebase Authentication and Cloud Firestore, and added a web application to your project.

  2. Edit the main.mjs inside the src folder of the project and add the Firebase config generated from the Firebase console for your project.

  3. Complete the code to sign up a user. Ensure that the success, or otherwise, of the operation is displayed inside the signupResults div. Remember you'll need to add an event listener to the appropriate button in order to run code when the button is clicked, see below.

  4. Test the signup form with an invalid email address, a password of less than 7 characters, and finally with valid details. Go onto the Firebase console and verify that, in the latter case, a user was signed up.

  5. Complete the code to login a user. Ensure that the success, or otherwise, of the operation is displayed inside the loginResults div. Try it out, both with a valid user and an invalid user. If successful, display the user's email via a Logged in as... message.

  6. Modify your signup code to set the user's display name, using the example shown in the notes.

  7. Add code to handle changes in authentication state, as shown above. This should behave as follows:

    • When a user has logged in, hide the login form and show the logout button and firestore div (this contains forms to interact with the Firestore database).
    • When a user has logged out, show the login form and hide the logout button and firestore div.

Firestore

If you finish the above exercise on Firebase Auth, start this Firestore exercise. If not, you can continue next week.

  1. Create a new collection called songs on Firestore via the Firebase Console.

  2. Add code to read data from the "add song" form. It should create a new document storing that song and add it to the songs collection.

  3. Add code to read the artist from the "search" form. It should query the Firestore database to find all songs by that artist, and display the results in the searchresults div.

  4. Add code to handle the "update" form. It should read in the ID, song, artist and year from the form fields. Then, depending on the value of the "action" select box, it should either update the document with that ID so that it contains the new values, or delete the document with that ID.

Reminder - handling button click events

document.getElementById("....").addEventListener("click", e => {
    // ...
});