Connecting the Frontend to the API Server | Full-Stack Google Contacts Clone with AdonisJS Framework (Node.js) and Quasar Framework (Vue.js)

Connecting the Frontend to the API Server | Full-Stack Google Contacts Clone with AdonisJS Framework (Node.js) and Quasar Framework (Vue.js)

Up till now, there is no link between the user interface of our Google Contacts Clone app and the API server. When we developed the API server, we used Postman to test the route. Now, we are ready to connect both worlds. In this lesson, we will introduce the axios HTTP request library and use it send requests to the API server. We will no longer use the mock data on the frontend. After connecting to the API server, we will make use of the mock data created with the Contact factory and seeded to the database. We will also configure Cross-Origin Resource Sharing (CORS) on our API server to restrict access to the API server.

Let's start by creating a new branch for our repo:

# Make sure you are within your project
git checkout -b 18-connect-the-frontend-to-the-api-server

Pre-requisites

Before we go into the major discussions, we need to install the dotenv module and add it to the quasar.conf.js file. The dotenv module makes environment variables defined in the .env file on the frontend to be available within the JavaScript code after the frontend is built. Why are we doing this? It is important to understand that the .env file used for storing environment variables on the frontend is not directly read like other JavaScript files - as a matter of fact, the file won't be copied to the dist directory when you build the frontend. In order to make use of those variables, they have to be compiled and injected into the JavaScript files so that they are available for use during the runtime. Typically, they are injected into the global process.env object. So if you have the following variables in the .env file:

NODE_ENV=development
API_HOST=127.0.0.1
API_PORT=3333

They will be accessible as: process.env.NODE_ENV, process.env.API_HOST, and process.env.API_PORT after they are injected. The dotenv NPM module makes this possible.

In most cases, you won't be able to destructure the process.env object. So don't do this: const {NODE_ENV, API_HOST, API_PORT} = process.env. Always access each variable through the full property path like process.env.NODE_ENV.

Install the dotenv module:

cd ui
yarn add -D dotenv

Open the ui/quasar.conf.js file. Refer to this snapshot. Within the build property, add the following line:

  build: {
+   env: require("dotenv").config().parsed,
     vueRouterMode: "hash", // available values: 'hash', 'history'
...
}

Let's all enable extra build options in the quasar.conf.js file:

build: {
    ...
+    preloadChunks: true,
+    showProgress: false,
+    gzip: true,
+    analyze: true,
    ...
}

Open the ui/.env file and add the following:

NODE_ENV=development
API_HOST=127.0.0.1
API_PORT=3333

Preparing the API Server

We will make few modifications to the API server before we face the modifications in the frontend. First, we ensure that the keys of the contact objects sent from the backend after serialisation are consistent with the types on the frontend. Let me clarify.

By default, AdonisJS will return the keys of your models in snake_case. These also match the names of columns on our database. For example: property firstName in our Contact model is stored as first_name on the contacts table. AdonisJS automatically does the case conversion when fetching or storing data via the default NamingStrategy. You should dedicate some time to study this. So, on the model file, the properties are defined in camelCase while on the database, the corresponding columns are defined in snake_case as shown below:

// App/Models/Contact
// Part of the column definitions for the `Contact` model on the backend
export default class Contact extends BaseModel {
...
   @column()
   public firstName: string // <-- Camel Case

   @column()
   public surname: string

   @column()
   public company?: string | null | undefined

   @column()
   public jobTitle?: string | null | undefined // <-- Camel Case
...
}

Meanwhile, by default, AdonisJS will return the properties in snake_case as defined on the database tables. The JSON below shows the default way the keys are returned after serialization.

Serialization is the process of converting objects into JSON format before data is sent to the client/backend. During serialisation, you can modify the way the keys and values each JSON entry are displayed.

{
    "id": "ckuyjdrqt002jwsvo465meq52",
    "first_name": "Angel",
    "surname": "Runolfsson",
    "company": "Jenkins - Muller",
    "job_title": null,
    "email1": "Angel65@hotmail.com",
    "email2": "Angel.Runolfsson@kory.com",
    "phone_number1": "955.249.9131 x63764",
    "phone_number2": "656.521.8253",
    "country": "Senegal",
    ...
    "created_at": "2021-10-19T21:25:57.000+01:00",
    "updated_at": "2021-10-19T21:25:57.000+01:00"
}

If we get the contact data in snake_case, it will lead to type mismatch on the frontend because the properties of Contact type are defined in camelCase as shown below:

// ui/src/types/index.ts
// The type definition for `Contact` on the frontend
interface Contact
  extends Record<string, string | null | undefined | number> {
  id: string;
  firstName: string;
  surname: string;
  company?: string | null | undefined;
  jobTitle?: string | null | undefined;
  email1: string;
  email2?: string | null | undefined;
  phoneNumber1: string;
  phoneNumber2?: string | null | undefined;
  country?: string | null | undefined;
  streetAddressLine1?: string | null | undefined;
  streetAddressLine2?: string | null | undefined;
  city?: string | null | undefined;
  state?: string | null | undefined;
  birthday?: string | null | undefined;
  website?: string | null | undefined;
  notes?: string | null | undefined;
}

So, how do we resolve this conflict? Gladly, AdonisJS can handle this gracefully. We only need to inform AdonisJS of how we want teach column property in the Contact model to be serialised. So open api/app/Models/Contact.ts and make the following changes. Refer to the snapshot for the updated file.

-  @column()
+  @column({ serializeAs: 'firstName' })
  public firstName: string

-  @column()
+  @column({ serializeAs: 'jobTitle' })
  public jobTitle?: string | null | undefined

-  @column()
+  @column({ serializeAs: 'phoneNumber1' })
  public phoneNumber1: string

-  @column()
+  @column({ serializeAs: 'phoneNumber2' })
  public phoneNumber2?: string | null | undefined

-  @column()
+  @column({ serializeAs: 'streetAddressLine1' })
  public streetAddressLine1?: string | null | undefined

-  @column()
+  @column({ serializeAs: 'streetAddressLine2' })
  public streetAddressLine2?: string | null | undefined

-  @column()
+  @column({ serializeAs: 'postCode' })
  public postCode?: string | null | undefined

-  @column.dateTime({ autoCreate: true })
+  @column.dateTime({ autoCreate: true, serializeAs: 'createdAt' })
  public createdAt: DateTime

-  @column.dateTime({ autoCreate: true, autoUpdate: true })
+  @column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: 'updatedAt' })
  public updatedAt: DateTime

As seen in the above diff, we are adding the property serializeAs to each column property which we need to serialise in camelCase. When you do this, the JSON data will be serialised in camelCase to match the types on the frontend.

{
    "id": "ckuyjdrqt002jwsvo465meq52",
    "firstName": "Angel",
    "surname": "Runolfsson",
    "company": "Jenkins - Muller",
    "jobTitle": null,
    "email1": "Angel65@hotmail.com",
    "email2": "Angel.Runolfsson@kory.com",
    "phoneNumber1": "955.249.9131 x63764",
    "phoneNumber2": "656.521.8253",
    "country": "Senegal",
    ...
    "createdAt": "2021-10-19T21:25:57.000+01:00",
    "updatedAt": "2021-10-19T21:25:57.000+01:00"
}

Configure Crosss-Origin Resource Sharing (CORS)

Since we are connecting to the API server from the frontend for the first time, we need to enable CORS (Cross-Origin Resource Sharing) on the API. But why do we need to enable CORS? CORS provides a layer of security for your backend by restricting clients which can connect to it. Imagine that we finally host our Google Contacts Clone app. If we choose to host the frontend at https://google-contacts-clone.app and the API server at https://google-contacts-clone.app/api, both apps (frontend and API) are on the same origin - google-contacts-clone.app. Since they are on the same origin, CORS won't have any effect. The browser will send the request with the confidence that the browser is recognised by the API server automatically because they are on teh same origin. However, we might need decide to host both apps on different origins, for example, the frontend at https://google-contacts-clone.app and API server at https://api.google-contacts-clone.app, the browser will enforce CORS restrictions and won't send the API request. Why? Because the browser does not trust that the frontend hosted at https://google-contacts-clone.app is authorised to access the API server at https://api.google-contacts-clone.app.

To solve this, AdonisJS has a CORS module which we can activated and configure the origins the API server should recognise. Open api/config/cors.ts. Refer to this snapshot for the updated file. Make the following changes:

-  enabled: false,
+  enabled: true,
    ...
-  origin: true,
+  origin: ['http://localhost:8008', 'http://127.0.0.1:8008'],

Above, we have enabled the CORS module and set the API server to only receive requests from either http://localhost:8008 or http://127.0.0.1:8008.

Why is this a security feature? It is possible for a malicious actor to attempt to send API requests from a different origin, maybe by cloning your app or embedding your app within another app/website. The browser will block such unauthorised cross-origin requests.

Note that CORS is a browser feature only. Someone can still send request from a terminal or an API tool such as Postman without being restricted by CORS. This is because requests sent from a browser have an origin whereas a terminal or Postman has no origin. This is the reason you must adequately hardened your API server.

We are done with the few changes on the API server. Save the files.

Improve the Frontend Types

Open the file ui/src/types/index.ts. Refer to this snapshot for the updated file. Copy-and-paste all the content from the snapshot to the ui/src/types/index.ts file.

We add the following interfaces:

export type PaginatedData = {
  data?: Record<string, unknown>;
  meta: {
    current_page: number;
    first_page: number;
    first_page_url: string;
    last_page: number;
    last_page_url: string;
    next_page_url: string;
    per_page: number;
    previous_page_url: string;
    total: number;
  };
};

export interface ResponseData {
  message?: string;
  status?: number;
  statusText?: string;
  stack?: string;
  data: Record<string, unknown>;
  errors?: Array<{ rule: string; field: string; message: string }>;
}

export interface HttpResponse extends AxiosResponse {
  data: ResponseData & string;
  message?: string;
  code?: string;
  stack?: string;
  headers: Record<string, string>;
}

export interface HttpError extends AxiosError {
  response?: HttpResponse;
}

export interface PaginatedContact {
  id: string;
  first_name: string;
  surname: string;
  email1: string;
  phone_number1: string;
  company: string;
  job_title: string;
}

Improve the Store Index

Open the file ui/src/store/index.ts. Refer to this snapshot for the updated file. Copy-and-paste all the content from the snapshot to the ui/src/store/index.ts file.

We are going to improve the store index file by adding more properties to the object returned from state() function and also defining getters to retrieve those state properties for us.

From Lines 23 to 32, we update the stateInterface and add the RootState interface.

export interface StateInterface {
-  // Define your own store structure, using submodules if needed
-  contacts: ContactStateInterface;
+  contacts?: ContactStateInterface;
+}
+
+ export interface RootState {
+   httpTimeout: number;
+   apiPort: string;
+   apiVersion: string | null;
+   apiProtocol: string;
+   apiHost: string;
}

From Line 48 to 76, we update the state() function within the store() function to:

    state() {
      return {
        apiHost: process.env.API_HOST ?? "127.0.0.1",
        apiPort: process.env.API_PORT ?? "3333",
        apiVersion: null,
        apiProtocol:
          window.location.hostname === "localhost"
            ? "http://"
            : ["staging", "production"].includes(process.env.NODE_ENV)
            ? "https://"
            : "http://",
        httpTimeout: process.env.NODE_ENV === "production" ? 60000 : 30000,
      };
    },

    getters: {
      getHttpProtocol: (state) => state.apiProtocol,
      getRootURL: (state) =>
        `${state.apiProtocol}${state.apiHost}:${state.apiPort}`,
      getBaseURL: (state) =>
        `${state.apiProtocol}${state.apiHost}:${state.apiPort}${
          state.apiVersion ? `/${state.apiVersion}` : ""
        }`,
      getHttpTimeout: (state) => state.httpTimeout,
      getHttpNoAuthOptions: (state, getters) => ({
        baseURL: getters.getBaseURL as string,
        timeout: getters.getHttpTimeout as string,
      }),
    },

Improve the Axios Instance

Open the file ui/src/boot/axios.ts. Refer to this snapshot for the updated file. Copy-and-paste all the content from the snapshot to the ui/src/boot/axios.ts file.

What happened in the ui/src/boot/axios.ts file:

At Line 1 and 2, we disable some eslint rules due to type issue with Vuex methods.

At Lines 5 and 6, we imported Notify and HttpError from quasar and src/types, respectively.

import { Notify } from "quasar";
import { HttpError } from "src/types";

At Line 14, we remove the options with baseURL while creating the axios instance. Why? We want to get the baseURL and timeout from the Vuex store and the Quasar provides the store in the context of the boot function. So we have to set the baseURL and timeoutwithin theboot` function.

- const api = axios.create({ baseURL: "https://api.example.com" });
+ const api = axios.create();

From Lines 16 to 18, we destructure store from the context of the boot function and set the default baseURL and timeout.

- export default boot(({ app }) => {
-  // for use inside Vue files (Options API) through this.$axios and this.$api
+ export default boot(({ app, store }) => {
+  api.defaults.baseURL = store.getters.getHttpNoAuthOptions.baseURL;
+  api.defaults.timeout = store.getters.getHttpNoAuthOptions.timeout;

From Lines 22 down, we define an axios response interceptor for the purpose of handling errors centrally.

api.interceptors.response.use(
    (response) => response,
    async (error: HttpError) => {
      console.log(error.response);
      if (error?.response?.status === 400) {
        console.log(error.response.data);

        Notify.create({
          message:
            error?.response?.data?.message ??
            (typeof error?.response?.data === "string"
              ? error?.response?.data
              : "You made a bad request."),
          type: "negative",
          position: "top",
          progress: true,
          timeout: 5000,
          actions: [
            {
              label: "Dismiss",
              color: "white",
            },
          ],
        });
      } else if (error?.response?.status === 403) {
        Notify.create({
          message:
            "You are not permitted to perform the requested action. Make sure that you are viewing the right company.",
          type: "negative",
          position: "top",
          progress: true,
          timeout: 5000,
          actions: [
            {
              label: "Dismiss",
              color: "white",
            },
          ],
        });
      } else if (error?.response?.status === 422) {
        // Intercept validation errors
        const validationErrors = error?.response?.data?.errors;
        if (Array.isArray(validationErrors) && validationErrors.length) {
          const errorListItems: string[] = validationErrors.map(
            (err) => `<li>${err.message}</li>`
          );
          Notify.create({
            message: `<ul>${errorListItems.join("")}</ul>`,
            html: true,
            type: "negative",
            position: "top",
            progress: true,
            timeout: 10000,
            actions: [
              {
                label: "Dismiss",
                color: "white",
              },
            ],
          });
        }
      } else if (error?.response?.status === 404) {
        Notify.create({
          message:
            error?.response?.data?.message ??
            (error?.response?.data as string) ??
            "Request resource was not found!",
          type: "negative",
          position: "top",
          progress: true,
          timeout: 5000,
          actions: [
            {
              label: "Dismiss",
              color: "white",
            },
          ],
        });
      } else if (
        error &&
        error.response &&
        error.response.status &&
        error.response.status >= 500
      ) {
        Notify.create({
          message:
            error?.response?.data?.message ??
            (error?.response?.data as string) ??
            "Internal Server Error",
          type: "negative",
          position: "top",
          progress: true,
          timeout: 5000,
          actions: [
            {
              label: "Dismiss",
              color: "white",
            },
          ],
        });
      }

      return Promise.reject(error);
    }
  );

Introduce API Requests in the actions of the Vuex contacts Module

Open the file ui/src/store/contacts/actions.ts. Refer to this snapshot for the updated file. Copy-and-paste all the content from the snapshot to the ui/src/store/contacts/actions.ts file.

What changes happened?

From Lines 1 to 2, we relax some eslint rules for obvious reasons. Then, from Lines 3 to 9, we import extra types from src/types.

At Line 13, we import the axios instance which was exported as api from boot/axios.ts.

Now, from Line 17, for the LOAD_CURRENT_CONTACT module action, we will substitute the logic of fetching data from local mock contacts source (data/Google_Contacts_Clone_Mock_Data) with a real API request.

const actions: ActionTree<ContactStateInterface, StateInterface> = {
  LOAD_CURRENT_CONTACT({ commit }, id: Contact["id"]): Promise<Contact> {
-    return new Promise((resolve, reject) => {
-      try {
-        const currentContact = rawContacts
-          .filter((contact) => contact.id === id)
-          .reduce((prev, cur) => {
-            prev = { ...cur };
-            return prev;
-          }, {} as Contact);
+    return new Promise(async (resolve, reject) => {
+      await api
+        .get(`/contacts/${id}`)
+        .then((response: HttpResponse) => {
+          const currentContact = response.data.data as Contact;
+          commit("setCurrentContact", currentContact);

-        commit("setCurrentContact", currentContact);
-
-        return resolve(currentContact);
-      } catch (error) {
-        return reject(error);
-      }
+          return resolve(currentContact);
+        })
+        .catch((error) => reject(error));
    });
  },

At Line 17, we make the promise callback async.

From Lines 18 to 22, we introduce the axios instance api and call the get method with the path /contacts/${id}. When the response is returned, at Line 22, we commit the setCurrentContact mutation with the currentContact as payload. We also resolve the currentContact and make it accessible to the initiating function which is the action dispatched in either the Contact View or Contact Edit views. At Line 26, we also reject any error and make the error accessible to the initiating function.

Similarly, for the LOAD_CONTACTS module action, we introduce the API call GET /contacts.

  LOAD_CONTACTS(
    { commit },
    { nextPage, pageSize }: { nextPage: number; pageSize: number }
-  ): Promise<Contact[]> {
-    return new Promise((resolve, reject) => {
-      try {
-        const requestedContacts = [...rawContacts].slice(
-          nextPage <= 1 ? 0 : (nextPage - 1) * pageSize,
-          nextPage <= 1 ? pageSize : nextPage * pageSize
-        );
+  ): Promise<ResponseData["data"]> {
+    return new Promise(async (resolve, reject) => {
+      await api
+        .get("/contacts", { params: { page: nextPage, perPage: pageSize } })
+        .then((response: HttpResponse) => {
+          const paginatedContacts = response.data.data
+            .data as PaginatedContact[];
+          const paginatedMeta = response.data.data
+            .meta as PaginatedData["meta"];

-        commit("setContactList", requestedContacts);
-        commit("setTotalContacts", rawContacts.length);
+        commit("setContactList", paginatedContacts);
+        commit("setTotalContacts", paginatedMeta.total);

-        return resolve(requestedContacts);
-      } catch (error) {
-        return reject(error);
-      }
+          return resolve(response.data.data);
+        })
+        .catch((error) => reject(error));
    });
  },
};

Minor Changes to Contacts Table

Open the file ui/src/pages/Index.vue. Refer to this snapshot for the updated file. Copy-and-paste all the content from the snapshot to the ui/src/pages/Index.vue file.

The following changes were made:

At Line 139, import the PaginatedData interface from types/index.ts.

From Lines 157 to 160, we modify the totalContacts computed property to allow setting of value. This demonstrates another syntax for creating computed properties in Vue 3.

-    const totalContacts = computed(
-      () => store.getters["contacts/totalContacts"] as number
-    );
+    const totalContacts = computed({
+      get: () => store.getters["contacts/totalContacts"] as number,
+      set: (value) => value,
+    });

The get method does the work of the familiar syntax of the computed property. While the set method allows explicitly setting the value from anywhere.

At Line 168, we get the resolved data from our action which contains the PaginatedData. At Line 172, we set the totalContacts computed property with the value data.meta.total. Line 172 triggers the set method in the computed property.

    const stopContactListEffect = watchEffect(async () => {
      await store
        .dispatch("contacts/LOAD_CONTACTS", {
          nextPage: nextPage.value,
          pageSize,
        })
-        .then(() => {
+        .then((data: PaginatedData) => {
          void nextTick(() => {
            tableRef.value?.refresh();
            loading.value = false;
+            totalContacts.value = data.meta.total;
          });
        });
    });

At Line 177, we convert the lastPage constant to a computed property.

At Lines 181 and 182, we simplify the strict equality and update lastPage to lastPage.value

-        loading.value !== true &&
-        nextPage.value < lastPage &&
+        loading.value === false &&
+        nextPage.value < lastPage.value &&

Minor Changes to the CreateContact.vue

Open the file ui/src/pages/contacts/CreateContact.vue. Refer to this snapshot for the updated file. Copy-and-paste all the content from the snapshot to the ui/src/pages/contacts/CreateContact.vue file.

Remove the Line import { contacts } from "../../data/Google_Contacts_Clone_Mock_Data";

At Line 286, make the following changes:

-       if (key !== "id") {
+      if (["id", "createdAt", "updatedAt"].includes(key) === false) {

Those are all the changes for the branch. Save all files, serve both the frontend and API server. Open the homepage and view a contact. The contacts should be fetched from the API server now. Press CTRL+Shift+I to open the devtools and switch to the Network tab to monitor the requests and responses.

# Serve the frontend
cd ui
yarn serve

# Split the terminal and serve the backend
cd api
yarn serve
git add .
git commit -m "feat(api): complete connection of frontend to the API server"
git push origin 18-connect-the-frontend-to-the-api-server
git checkout master
git merge master 18-connect-the-frontend-to-the-api-server
git push origin master