DEV Community

Bernardo Cassina
Bernardo Cassina

Posted on

Firebase Functions Express Typescript Project Guide Part 2

Requirements

In this part we will create a REST API to perform CRUD operations in a Pets resource. We will be able to use this API to interact with our Pets Firestore Collection like dogs or cats.

First it is important to understand the Best Practices of REST API Design, please read this article.

Note: Remember to build the Functions project every time you make a new change with npm run buil this will recreate the lib directory.

0. Design First Path

  • Now, that you have read about the best practices let’s design our paths. Let’s say that our default URL will be http://127.0.0.1:5001/my-project/us-central1/api
  • First we want to create Pets, so our first path should be /pets . This endpoint will create a brand new pet and it should return the new resource created along with its database ID, so we can identify it later.
  • The HTTP method we use to create a new resource is POST so we will create a new function that accepts a POST request and creates a new Pet
  • It will look something like this:

    app.post("/pets", (req: Request, res: Response) => // create a new pet );
    

1. Create Pet Model

  • Now that we created our first POST path, we now we want to create Pets, but how would a pet look like, what properties should it have? Let’s use a simple example, let’s say a pet has these properties:

    Pet
        - id
      - category
      - name
      - tags
    
  • Now all of this properties need a Firestore Type like boolean, string or number. Let’s define them:

    Pet
        - id: string;
      - category: string;
      - name: string;
      - tags: Array of strings;
    
  • Now we have the properties for our Pet model, we need to use the power of Typescript to define this model in our code.

  • First let’s define what Typescript uses interfaces and types for [GPT complete this part]

  • Now that we now how interfaces and types work, let’s create our Pet interface:

    interface Pet {
        id: string;
      category: string;
      name: string;
      tags: string[]; // Array of strings
    }
    
  • Now, probably we want to have explicit categories, so we don’t pets without a valid category, we can do that with a Type:

    type PetCategory = 'dog' | 'cat' | 'bird'
    
  • And the we update our Pet interface:

    interface Pet {
        id: string;
      category: PetCategory;
      name: string;
      tags: string[]; // Array of strings
    }
    
  • This way we will make sure only strings of those values can be passed to a Pet interface and if not, the TS compiler will throw an error.

  • Now that we have our type and interfaces, let’s add them to our code. The good practice is to create separate files for them an import them as necessary. Let’s create an interfaces.ts and types.ts inside the src directory next to the index.ts file.

  • The content of the types file should be:

    export type PetCategory = "dog" | "cat" | "bird";
    
  • And the interfaces file should look like this:

    // We create a relative import to use the PetCategory type in our interface
    import {PetCategory} from "./types";
    
    export interface Pet {
        id: string;
        category: PetCategory;
        name: string;
        tags: string[]; // Array of strings
    }
    
    
  • Great! Now we have a new Pet model.

2. Initialize Firestore

  • First let’s add the Firestore emulator, otherwise all calls will be added to your Firebase Project’s Firestore Database you created un part 1 and we want to test in our local environment first.
  • Let’s modify the serve script in package.json file to add Firestore emulators:

    "serve": "npm run build && firebase emulators:start --only=functions,firestore"
    
  • Let’s run npm serve and you should see something like this:

    i  firestore: Firestore Emulator logging to firestore-debug.log
      firestore: Firestore Emulator UI websocket is running on 9150.
    
    ...
    
    ┌───────────┬────────────────┬─────────────────────────────────┐
     Emulator   Host:Port       View in Emulator UI             
    ├───────────┼────────────────┼─────────────────────────────────┤
     Functions  127.0.0.1:5001  http://127.0.0.1:4000/functions │
    ├───────────┼────────────────┼─────────────────────────────────┤
     Firestore  127.0.0.1:8080  http://127.0.0.1:4000/firestore │
    └───────────┴────────────────┴─────────────────────────────────┘
    
  • Now let’s initialize Firestore in the main.ts file:

    ...other code
    
    // Import Firebase admin to use Firestore
    import * as admin from "firebase-admin";
    
    // Import the Pet interface
    import {Pet} from "./interfaces";
    
    // Initialize Firebase Admin SDK
    admin.initializeApp();
    
    // Firestore database reference
    const db = admin.firestore();
    
  • Let’s also use express.json in our express app to parse requests:

    
    ...other code
    
    const app = express();
    
    // Middleware to parse JSON requests
    app.use(express.json());
    
  • Now build the project to update the emulators server

3. Create new Pet

  • Let’s write a function to create the new Pet, but first read this:

    // Define a POST route for creating new pets
    app.post("/pets", async (req: Request, res: Response) => {
      // Extract the new pet data from the request body
      const newPet: CreatePetBody = req.body;
    
      try {
        // Add the new pet to the "pets" collection in Firestore
        const docRef = await db.collection("pets").add(newPet);
    
        // Create a pet document with the generated ID and pet data
        const petDocument: PetDocument = {id: docRef.id, ...newPet};
    
        // Send the created pet document as the response with status 201
        res.status(201).send(petDocument);
      } catch (error) {
        // Send an error response with status 500 if something goes wrong
        res.status(500).send("Error creating new pet: " + error);
      }
    });
    
  • Now build the project to update the emulators server

  • Open postman, create a new POST request to the default URL and the path /pets

  • Select the Body, then select Raw and JSON

  • Now add the following:

    {
        "category": "dog",
        "name": "Buddy",
        "tags": ["friendly", "playful"]
    }
    
  • If it worked, the Postman response should look like this:

    {
        "id": "Jth82AgC7CIMxxXQKPEF",
        "category": "dog",
        "name": "Buddy",
        "tags": [
            "friendly",
            "playful"
        ]
    }
    
  • Now we can use that id to retrieve that specific pet in the future and if you go to http://localhost:4000/firestore you should see the new data in the emulator

4. What if some parameters are missing?

  • So what happens if someone tries to create a Pet without a name? We will create a Document that looks like this:

    {
        "id": "Jth82AgC7CIMxxXQKPEF",
        "category": "dog",
        "tags": [
            "friendly",
            "playful"
        ]
    }
    
  • This is not correct and can lead to data corruption, we need to make sure we are persisting the data following our interfaces and type models. So let’s add a check:

    if (!newPet.name) {
        res.status(400).send("Bad request: missing name");
      }
    
  • Also we should check that the category is passed as an argument:

    if (!newPet.category) {
        res.status(400).send("Bad request: missing category");
      }
    
  • Try to create a pet without category or name, you should receive a Bad request: missing name response

  • What about the tags? The tags is an array of strings, we can either set it as an empty array or as an optional property. Let’s add it as optional, modify the interface to add a ? that tells typescript this is an optional property:

    import {PetCategory} from "./types";
    
    export interface Pet {
        category: PetCategory;
        name: string;
        tags?: string[]; // Optional ? Array of strings
    }
    
  • Cool. Now we can create Pets.

5. GET Pets and filter by category and tag

After creating the Pets, it’s time to read those Pets:

  • Read this first to learn how to get data from Firestore
  • Now let’s get all our pets:

    app.get("/pets", async (req: Request, res: Response) => {
      try {
        // Reference to the pets collection
        // CollectionReference: https://firebase.google.com/docs/reference/node/firebase.firestore.CollectionReference
        const petsRef: admin.firestore.Query = db.collection("pets");
    
        // Fetch pets from Firestore
        // This declaration returns a Firestore QuerySnapshot:
        // https://firebase.google.com/docs/reference/node/firebase.firestore.QuerySnapshot
        const snapshot = await petsRef.get();
    
        // Why do we need to iterate over docs? We need to iterate
        // over docs to convert each document into a usable object
        const pets: PetResponse[] = snapshot.docs
          .map((docSnapshot: admin.firestore.QueryDocumentSnapshot) => {
            // What is "as"? "as" is a TypeScript type assertion,
            // telling the compiler that docSnapshot.data() is of type Pet
            const pet: Pet = docSnapshot.data() as Pet;
    
            // Return the data along with the Document ID
            // Combining the document ID with the pet data into one object
            return {
              id: docSnapshot.id,
              data: pet,
            };
          });
    
        // Send the processed and filtered pets as the response
        res.status(200).send(pets);
      } catch (error) {
        // Send an error response with status 500 if something goes wrong
        res.status(500).send("Error reading pets: " + error);
      }
    });
    
    
  • So what if we want to search for pets that have only the dog category. In REST API design, we can do that with query parameters added to our path like so:

    ...api/pets?category=dog
    
  • We can other queries as well:

    ...api/pets?category=dog&tag=intelligent
    
  • This way we can tell Firestore to filter the results like this:

    db.collection('pets').where('category', '==', 'dog');
    
  • So the final code should look like this:

app.get("/pets", async (req: Request, res: Response) => {
  try {
    // Get query parameters
    const {category, tag} = req.query;

    // Reference to the pets collection
    // CollectionReference: https://firebase.google.com/docs/reference/node/firebase.firestore.CollectionReference)
    let petsRef: admin.firestore.Query = db.collection("pets");

    // Apply category filter if provided
    if (category) {
      petsRef = petsRef.where("category", "==", category);
    }

    // Apply category filter if provided
    if (tag) {
      petsRef = petsRef.where("tag", "==", tag);
    }

    // Fetch pets from Firestore
    // This declaration returns a Firestore QuerySnapshot:
    // https://firebase.google.com/docs/reference/node/firebase.firestore.QuerySnapshot
    const snapshot = await petsRef.get();

    // What's going on? Why do we need to iterate over docs?
    const pets: PetResponse[] = snapshot.docs
      .map((docSnapshot: admin.firestore.QueryDocumentSnapshot) => {
        // What's going on here? What is "as"?
        const pet: Pet = docSnapshot.data() as Pet;

        // Return the data along with the Document ID
        return {
          id: docSnapshot.id,
          data: pet,
        };
      });

    // Send the processed and filtered pets as the response
    res.status(200).send(pets);
  } catch (error) {
    // Send an error response with status 500 if something goes wrong
    res.status(500).send("Error reading pets: " + error);
  }
});
Enter fullscreen mode Exit fullscreen mode

Hell yeah! Now we have created and filtered virtual animals.

Top comments (0)