DEV Community

Igor Dubinin
Igor Dubinin

Posted on • Updated on

Microservices for beginners. Front-end service. Vue js. Socket.io.

Whole series:

Microservices for beginners

Microservices for beginners. Front-end service. Vue js. Socket.io.

Microservices for beginners. Api Gateway service. Nest js. Kafka.

Microservices for beginners. User service. Nest js. Mongodb. Kafka.

Microservices for beginners. Message service. Nest js. Mongodb. Kafka.

Microservices for beginners. Spam service. Python. Scikit-learn. Kafka.

Microservices for beginners. Toxic service. Python. Tensorflow. Kafka.

Full code of example - link

Functional requirements for my front-end service:

1) Provide registration

Registration

2) Provide login

Login

3) Provide list of users

Users list

4) Provide conversation between users

Conversation

Additionally:

  • Application does look for bad guys

Bad guys

Bad guys

Bad guys

  • Application does look for spammers

Spam

Spam

5) Not allow blocked users to login again

Blocked

Implementation

Components

Here I will show how to create a little front-end application, with all described functionality. I use the vue framework and vuetifyjs as a ui library. I use axios for http requests, with JWT token for user access and socket.io for messaging. I use pinia to have storage and share data between different views and components.

First file - main.ts initiation of service.

import { createApp } from "vue";
import { createPinia } from "pinia";

import App from "./App.vue";
import router from "./router";

import "@mdi/font/css/materialdesignicons.css";
import "vuetify/styles";
import { createVuetify } from "vuetify";
import * as components from "vuetify/components";
import * as directives from "vuetify/directives";

const vuetify = createVuetify({
  components,
  directives,
});

const app = createApp(App);
app.config.errorHandler = (error, vm, info) => {
  console.log("error", error, "info", info);
};
app.use(createPinia());
app.use(router);
app.use(vuetify);
app.mount("#app");

Enter fullscreen mode Exit fullscreen mode

App.vue - this is the layout. Layout contains two components SideMenu.vue and HeaderAlert.vue.

<script lang="ts">
import SideMenu from "./components/SideMenu.vue";
import HeaderAlert from "./components/HeaderAlert.vue";
import { useHeaderAlertStore } from "./stores/header-alert";

export default {
  data: () => ({
    toggle: true,
  }),
  methods: {
    toggleDrawer() {
      this.toggle = !this.toggle;
    },
  },
  components: { SideMenu, HeaderAlert },
  errorCaptured(error: any) {
    const headerAlertStore = useHeaderAlertStore();
    headerAlertStore.setError(error);
    if (error.response?.status === 401) {
      this.$router.push({ name: "login" });
    }
  },
};
</script>

<template>
  <v-app>
    <!-- header -->
    <v-app-bar>
      <v-app-bar-nav-icon
        variant="text"
        @click.stop="toggleDrawer"
      ></v-app-bar-nav-icon>

      <v-btn icon>
        <v-icon>mdi-magnify</v-icon>
      </v-btn>

      <v-btn icon>
        <v-icon>mdi-heart</v-icon>
      </v-btn>
    </v-app-bar>

    <!-- side menu -->
    <SideMenu v-model="toggle"></SideMenu>

    <!--  content -->
    <v-main>
      <v-container>
        <HeaderAlert></HeaderAlert>
        <RouterView />
      </v-container>
    </v-main>
  </v-app>
</template>
Enter fullscreen mode Exit fullscreen mode

HeaderAlert.vue - shows notifications on the top of the page.

<script lang="ts">
import { useHeaderAlertStore } from "../stores/header-alert";

export default {
  data: function () {
    const headerAlertStore = useHeaderAlertStore();
    return {
      headerAlertStore: headerAlertStore,
    };
  },
};
</script>

<template>
  <v-alert
    v-if="Boolean(headerAlertStore.success)"
    icon="mdi-alert-circle-outline"
    variant="outlined"
    color="success"
    title="Success"
  >
    {{ headerAlertStore.success?.message }}
  </v-alert>

  <v-alert
    v-if="Boolean(headerAlertStore.error)"
    icon="mdi-alert-circle-outline"
    variant="outlined"
    color="error"
    title="Error"
  >
    {{ headerAlertStore.error?.message }}
  </v-alert>

  <br />
</template>
Enter fullscreen mode Exit fullscreen mode

SideMenu.vue - contains links from the left menu of the page, links should be different, depending on the user state - logged or not logged.

<script lang="ts">
import { useAuthStore } from "../stores/auth";

export default {
  data: function () {
    const authStore = useAuthStore();
    return {
      title: "Application",
      authStore,
    };
  },

  methods: {
    logout: function () {
      this.authStore.logout();
      this.$router.push({ name: "login" });
    },
  },
};
</script>

<template>
  <v-navigation-drawer>
    <v-list>
      <v-list-item>
        <v-row no-gutters>
          <v-col cols="3">
            <v-avatar image="/src/assets/logo.svg" rounded="0"></v-avatar>
          </v-col>
          <v-col>
            <v-list-item-title>{{ title }} </v-list-item-title>
            <v-list-item-subtitle>Hello!</v-list-item-subtitle>
          </v-col>
        </v-row>
      </v-list-item>
    </v-list>

    <v-divider></v-divider>

    <v-list>
      <v-list-item title="Home" value="Home" prepend-icon="mdi-home" to="/">
      </v-list-item>

      <v-list-item
        v-if="!authStore?.auth"
        title="Login"
        value="Login"
        prepend-icon="mdi-login"
        to="/login"
      >
      </v-list-item>

      <v-list-item
        v-if="!authStore?.auth"
        title="Registration"
        value="Registration"
        prepend-icon="mdi-account-plus"
        to="/registration"
      >
      </v-list-item>

      <v-list-item
        v-if="authStore?.auth"
        title="Users"
        value="Users"
        prepend-icon="mdi-account-multiple"
        to="/users-list"
      >
      </v-list-item>
    </v-list>

    <v-divider></v-divider>

    <v-list v-if="authStore?.auth">
      <v-list-item
        title="Logout"
        value="Logout"
        prepend-icon="mdi-logout"
        @click="logout"
      >
      </v-list-item>
    </v-list>
  </v-navigation-drawer>
</template>
Enter fullscreen mode Exit fullscreen mode

User login happens in the LoginView.vue, but I need to change links in the side menu for logged users. To have actual user's state I will use stores/auth.ts

import { defineStore } from "pinia";

export const useAuthStore = defineStore("counter", {
  state: () => {
    const accessToken: string | null = localStorage.getItem("accessToken");
    const refreshToken: string | null =
      localStorage.getItem("refreshToken");
    return {
      accessToken,
      refreshToken,
      auth: Boolean(accessToken),
    };
  },
  actions: {
    refresh({ accessToken, refreshToken }: { accessToken: string, refreshToken: string }) {
      localStorage.setItem("accessToken", accessToken);
      localStorage.setItem("refreshToken", refreshToken);

      this.accessToken = accessToken;
      this.refreshToken = refreshToken;
    },

    login({ accessToken, refreshToken }: { accessToken: string, refreshToken: string }) {
      localStorage.setItem("accessToken", accessToken);
      localStorage.setItem("refreshToken", refreshToken);

      this.accessToken = accessToken;
      this.refreshToken = refreshToken;
      this.auth = true;
    },

    logout() {
      localStorage.removeItem("accessToken");
      localStorage.removeItem("refreshToken");

      this.accessToken = null;
      this.refreshToken = null;
      this.auth = false;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Errors may happen in any view after a user's actions or http request, but I always want to show these at the top of the page. To have data for alerts in the header I will use stores/header-alert.ts.

import { defineStore } from "pinia";

export const useHeaderAlertStore = defineStore("headerAlert", {
  state: () => {
    return {
      error: undefined,
      success: undefined,
    };
  },
  actions: {
    clear(type: string, dt: number) {
      setTimeout(() => {
        if (this[type]?.dt === dt) {
          this[type] = undefined;
        }
      }, 10000);
    },
    setError(error: any) {
      this.success = undefined;

      const dt = Date.now();
      this.error = {
        ...error,
        dt,
        message: error.response?.data?.message || error.message,
      };
      this.clear("error", dt);
    },
    setSuccess(success: any) {
      this.error = undefined;

      const dt = Date.now();
      this.success = {
        dt,
        message: success.response?.data?.message || success.message,
        data: success.response?.data || success.data,
      };

      this.clear("success", dt);
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

Router - router/index.ts contains the list of routes and views for them.

import { createRouter, createWebHistory } from "vue-router";

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: "/",
      name: "home",
      component: () => import("../views/HomeView.vue"),
    },
    {
      path: "/login",
      name: "login",
      component: () => import("../views/LoginView.vue"),
    },
    {
      path: "/registration",
      name: "registration",
      component: () => import("../views/RegistrationView.vue"),
    },
    {
      path: "/users-list",
      name: "users-list",
      component: () => import("../views/UsersListView.vue"),
    },
    {
      path: "/chat-private/:userId",
      name: "chat-private",
      component: () => import("../views/ChatPrivateView.vue"),
    },
  ],
});

export default router;
Enter fullscreen mode Exit fullscreen mode

RegistrationView.vue - implements registration of users.

<script lang="ts">
import { registerUser } from "@/repos/users";
import { isEmailValid } from "@/utils/conditions";
import { useHeaderAlertStore } from "@/stores/header-alert";

const headerAlertStore = useHeaderAlertStore();

export default {
  data: () => ({
    valid: false,

    showPwd: false,
    showRPwd: false,

    login: "",
    loginRules: [(value: string) => !!value || "Login is required"],

    email: "",
    emailRules: [
      (value: string) => !!value || "E-mail is required",
      (value: string) => {
        return isEmailValid(value) || "E-mail must be valid";
      },
    ],

    password: "",
    passwordRules: [(value: string) => !!value || "Password is required"],

    passwordRepeat: "",

    terms: false,
    termsRules: [(value: string) => !!value || "Accept of terms is required"],
  }),
  methods: {
    async sendRegistration(event: any) {
      event?.preventDefault();

      try {
        const { valid } = await this.validateField();
        if (!valid) {
          return;
        }
        const { login, email, password, passwordRepeat } = this;
        await registerUser({
          login,
          email,
          password,
          passwordRepeat,
        });

        headerAlertStore.setSuccess({
          message: "Registration completed! Let's try to login!",
        });
        setTimeout(() => {
          this.$router.push({ name: "login" });
        }, 1000);
      } catch (error) {
        headerAlertStore.setError(error);
      }
    },
    passwordRepeatRules(value: string) {
      return value === this.password || "Repeat password please!";
    },
    validateField() {
      return this.$refs.form.validate();
    },
  },
  watch: {
    login: "validateField",
    email: "validateField",
    password: "validateField",
    passwordRepeat: "validateField",
    terms: "validateField",
  },
};
</script>

<template>
  <v-form ref="form" v-model="valid" lazy-validation @submit="sendRegistration">
    <v-card class="mx-auto" title="User Registration">
      <v-container>
        <v-text-field
          v-model="login"
          :rules="loginRules"
          color="primary"
          label="Login"
          variant="underlined"
          required
        ></v-text-field>

        <v-text-field
          v-model="email"
          :rules="emailRules"
          color="primary"
          label="Email"
          variant="underlined"
          required
        ></v-text-field>

        <v-text-field
          v-model="password"
          :rules="passwordRules"
          :append-icon="showPwd ? 'mdi-eye' : 'mdi-eye-off'"
          :type="showPwd ? 'text' : 'password'"
          color="primary"
          label="Password"
          placeholder="Enter your password"
          variant="underlined"
          required
          @click:append="showPwd = !showPwd"
        ></v-text-field>

        <v-text-field
          v-model="passwordRepeat"
          :rules="[passwordRepeatRules]"
          :append-icon="showRPwd ? 'mdi-eye' : 'mdi-eye-off'"
          :type="showRPwd ? 'text' : 'password'"
          color="primary"
          label="Repeat password"
          placeholder="Repeat your password"
          variant="underlined"
          required
          @click:append="showRPwd = !showRPwd"
        ></v-text-field>

        <v-checkbox
          v-model="terms"
          :rules="termsRules"
          color="secondary"
          label="I agree to site terms and conditions"
        ></v-checkbox>
      </v-container>

      <v-divider></v-divider>

      <v-card-actions>
        <v-spacer></v-spacer>

        <v-btn color="primary" type="submit">
          Complete Registration

          <v-icon icon="mdi-chevron-right" end></v-icon>
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-form>
</template>
Enter fullscreen mode Exit fullscreen mode

LoginView.vue - implements login of users.

<script lang="ts">
import { loginUser } from "@/repos/users";
import { useHeaderAlertStore } from "@/stores/header-alert";

const headerAlertStore = useHeaderAlertStore();

export default {
  data: () => ({
    valid: false,

    showPwd: false,

    login: "",
    loginRules: [(value: string) => !!value || "Login is required"],

    password: "",
    passwordRules: [(value: string) => !!value || "Password is required"],
  }),
  methods: {
    async sendLogin(event: any) {
      event.preventDefault();
      const { valid } = await this.validateField();
      if (!valid) {
        return;
      }
      try {
        const { login, password } = this;
        await loginUser({
          login,
          password,
        });

        headerAlertStore.setSuccess({
          message: "Login successful. Welcome!",
        });

        setTimeout(() => {
          this.$router.push({ name: "users-list" });
        }, 1000);
      } catch (error) {
        headerAlertStore.setError(error);
      }
    },
    validateField() {
      return this.$refs.form.validate();
    },
  },
  watch: {
    login: "validateField",
    password: "validateField",
  },
};
</script>

<template>
  <v-form ref="form" v-model="valid" lazy-validation @submit="sendLogin">
    <v-card class="mx-auto" title="Authentication">
      <v-container>
        <v-text-field
          v-model="login"
          :rules="loginRules"
          color="primary"
          label="Login"
          variant="underlined"
          required
        ></v-text-field>

        <v-text-field
          v-model="password"
          :rules="passwordRules"
          :append-icon="showPwd ? 'mdi-eye' : 'mdi-eye-off'"
          :type="showPwd ? 'text' : 'password'"
          color="primary"
          label="Password"
          placeholder="Enter your password"
          variant="underlined"
          required
          @click:append="showPwd = !showPwd"
        ></v-text-field>
      </v-container>

      <v-divider></v-divider>

      <v-card-actions>
        <v-spacer></v-spacer>
        <v-btn color="primary" type="submit">
          Authentication
          <v-icon icon="mdi-chevron-right" end></v-icon>
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-form>
</template>
Enter fullscreen mode Exit fullscreen mode

UsersListView.vue - implements list of users. Here I make the a of users with pagination.

<script lang="ts">
import { getUsersList } from "@/repos/users";
import { useHeaderAlertStore } from "@/stores/header-alert";

const headerAlertStore = useHeaderAlertStore();

export default {
  data: () => ({
    users: [],
    count: 0,
    skip: 0,
    limit: 5,
    page: 1,
    pagesLength: 1,
    error: undefined,
  }),
  methods: {
    async getUsers() {
      try {
        const { users, count } = await getUsersList({
          skip: this.skip,
          limit: this.limit,
        });
        this.users = users;
        this.count = count;
        this.pagesLength = Math.ceil(this.count / this.limit);
      } catch (error: any) {
        headerAlertStore.setError(error);
        if (error.response?.status === 401) {
          this.$router.push({ name: "login" });
        }
      }
    },
  },
  async mounted() {
    this.getUsers();
  },
  watch: {
    page: function (page) {
      this.skip = (page - 1) * this.limit;
      this.getUsers();
    },
  },
};
</script>

<template>
  <v-table hover=true>
    <thead>
      <tr>
        <th class="text-left">Login</th>
        <th class="text-left">Actions</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="user in users" :key="user.id">
        <td>{{ user.login }}</td>
        <td>
          <v-btn
            prepend-icon="mdi-chat-outline"
            variant="outlined"
            color="primary"
            size="small"
            :to="`chat-private/${user.id}`"
            >Message</v-btn
          >
        </td>
      </tr>
    </tbody>
  </v-table>

  <div class="text-center">
    <v-container>
      <v-row justify="center">
        <v-col cols="8">
          <v-container class="max-width">
            <v-pagination
              class="my-4"
              :length="pagesLength"
              v-model="page"
            ></v-pagination>
          </v-container>
        </v-col>
      </v-row>
    </v-container>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

ChatPrivateView.vue - implements messenger for users. I use socket.io for messaging and send the authorization token inside every message, because socket.io has difficulties with updating data in headers.

<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { useHeaderAlertStore } from "@/stores/header-alert";
import { io } from "socket.io-client";
import { useAuthStore } from "@/stores/auth";
import { refreshTokenRequest } from '@/utils/axios';
import type { messageDto } from '@/dto/messages';
import type { userDto } from '@/dto/users';

const headerAlertStore = useHeaderAlertStore();
const authStore = useAuthStore();
const apiGatewayUrl = import.meta.env.VITE_SOCKET_URI;

const messages: messageDto[] = [];
const usersById: { [key: string]: userDto } = {};

export default {
  data: () => ({
    socket: io(apiGatewayUrl),
    message: "",
    messages,
    loading: false,
    disabled: false,
    roomId: '',
    uuid: '',
    usersById,
  }),
  async mounted() {
    try {
      this.socket.on("exception", async (error: any) => {
        try {
          if (error.message === 'jwt expired') {
            await refreshTokenRequest();
            if (!this.roomId) {
              this.joinRoom();
            }
            this.sendMessage(undefined);
          } else {
            throw error;
          }
        } catch (err: any) {
          headerAlertStore.setError(err);
          if (err.response?.status === 401) {
            this.$router.push({ name: "login" });
          }
        }
      });

      this.socket.on("connect", () => {
        this.joinRoom();
      });

      this.socket.on("joinPrivateRoom", (data) => {
        this.roomId = data.room.id;
        this.usersById = data.users.reduce((acc: { [key: string]: userDto }, user: userDto) => {
          acc[user.id] = user;
          return acc;
        }, {});

        this.messages = data.messages.map(({ uuid, user_id, message, created_at }: messageDto) => ({
          uuid,
          user_id,
          message,
          created_at,
          login: this.usersById[user_id]?.login,
        }));
        this.scrollDown();
      });

      this.socket.on("message", (data) => {
        const { uuid, user_id, message, created_at, } = data;
        this.messages.push({
          uuid,
          user_id,
          message,
          created_at,
          login: this.usersById[user_id]?.login,
        });

        if (uuid === this.uuid) {
          this.loading = false;
          this.disabled = false;
          this.message = '';
        }

        this.scrollDown();
      });

      this.socket.on("disconnect", () => {
        console.log("Disconnected");
      });


    } catch (error) {
      headerAlertStore.setError(error);
    }
  },
  beforeUnmount() {
    this.socket?.disconnect()
  },
  methods: {
    sendMessage(event: any|undefined) {
      event?.preventDefault();

      if (this.message) {
        this.loading = true;
        this.disabled = true;
        this.uuid = uuidv4();
        this.socket?.emit("message", {
          token: authStore.accessToken,
          message: this.message,
          uuid: this.uuid,
          room_id: this.roomId,
        });
      }

    },
    joinRoom() {
      this.socket.emit("joinPrivateRoom", {
        token: authStore.accessToken,
        userId: this.$route.params.userId,
        type: 'private',
      });
    },
    scrollDown() {
      const el = this.$refs.messages_container;
      if (this.messages?.length > 5) {
        setTimeout(() => {
          el.scrollToIndex(this.messages.length - 1);
        }, 1000);
      }
    }
  },
};
</script>

<template>
  <v-card class="mx-auto" title="Chat">
    <v-card-text>
      <v-virtual-scroll
        :height="300"
        :items="messages"
        ref="messages_container"
      >
        <template v-slot:default="{ item }">
          <v-list-item-title>{{ item.login }}</v-list-item-title>
          <v-list-item-subtitle>{{ new Date(item.created_at).toLocaleString() }}: {{ item.message }}</v-list-item-subtitle>
        </template>
      </v-virtual-scroll>
    </v-card-text>

    <v-card-actions>
      <v-container>
        <v-row dense>
          <v-col cols="12">

            <v-form ref="form" @submit="sendMessage">
              <v-text-field
                ref="message_field"
                :loading="loading"
                :disabled="disabled"
                label="Message"
                append-inner-icon="mdi-send"
                v-model="message"
                @click:append-inner="sendMessage"
              >
              </v-text-field>
            </v-form>

          </v-col>
        </v-row>
      </v-container>
    </v-card-actions>

  </v-card>
</template>
Enter fullscreen mode Exit fullscreen mode

I need to get data for my interface or write something in my application, and I use one file which provides access to data repos/users.ts.

import axios from "axios";
import { authenticatedRequest } from '@/utils/axios';
import { useAuthStore } from "@/stores/auth";
import type { userListDto, usersListQueryDto } from "@/dto/users";

const authStore = useAuthStore();
const apiGatewayUrl = import.meta.env.VITE_API_GATEWAY_URI;


// registration of user
export const registerUser = async (param: any) => {
  const { data } = await axios.post(`${apiGatewayUrl}/auth/registration`, param);
  return data;
};

// login of user
export const loginUser = async (param: any) => {
  const { data } = await axios.post(`${apiGatewayUrl}/auth/login`, param);
  const { accessToken, refreshToken } = data;

  authStore.login({ accessToken, refreshToken });
  return { accessToken, refreshToken };
};

// get list of users
export const getUsersList = async (
  param: usersListQueryDto
): Promise<userListDto> => {
  const url = new URL('/users/find-all', apiGatewayUrl);
  Object.entries(param).forEach(([ key, value ]) => url.searchParams.append(key, value?.toString()));

  return authenticatedRequest("get", url.href);
};
Enter fullscreen mode Exit fullscreen mode

Different repositories may make different http requests with different data, but common part with implementation of http request with JWT token I put into infrastructure file utils/axios.ts

import axios from "axios";
import { useAuthStore } from "@/stores/auth";

const authStore = useAuthStore();
const apiGatewayUrl = import.meta.env.VITE_API_GATEWAY_URI;

// request with Bearer token
const requestWithToken = async (method: string, url: string, param: any) => {
  const config = {
    headers: {
      Authorization: `Bearer ${authStore.accessToken}`,
    },
  };

  const result =
    method === "post"
      ? await axios.post(url, param, config)
      : await axios.get(url, config);
  return result.data;
};

// request for refresh Bearer token
export const refreshTokenRequest = async () => {
  const refreshTokenOld = localStorage.getItem("refreshToken");
  const config = {
    headers: {
      Authorization: `Bearer ${refreshTokenOld}`,
    },
  };

  const result = await axios.post(`${apiGatewayUrl}/auth/refresh`, {}, config);

  const { accessToken, refreshToken } = result.data;
  authStore.refresh({ accessToken, refreshToken });

  return { accessToken, refreshToken };
};

// request with token and refresh old
export const authenticatedRequest = async (
  method: string,
  url: string,
  param?: any
) => {
  try {
    return await requestWithToken(method, url, param);
  } catch (error: any) {
    if (error.response?.status === 401) {
      try {
        await refreshTokenRequest();

        return await requestWithToken(method, url, param);
      } catch (secondError) {
        authStore.logout();
        throw secondError;
      }
    }

    throw error;
  }
};
Enter fullscreen mode Exit fullscreen mode

Top comments (0)