This is the 7th part of a series of articles under the name "Play Microservices". Links to other parts:
Part 1: Play Microservices: Bird's eye view
Part 2: Play Microservices: Authentication
Part 3: Play Microservices: Scheduler service
Part 4: Play Microservices: Email service
Part 5: Play Microservices: Report service
Part 6: Play Microservices: Api-gateway service
Part 7: You are here
Part 8: Play Microservices: Integration via docker-compose
Part 9: Play Microservices: Security
The source code for the project can be found here:
Contents:
- Summary
- Tools
- Docker dev environment
- API mock service: Wiremock
- Client service: Typescript
- To do
- Summary
In the previous stages, we successfully developed all of back-end services including the auth, scheduler, email, report and api-gateway services. Our current objective is to establish a client service that acts as a single entry point for end users to access our application. As we independently develop the client service, the remaining services are unavailable to us during development. To overcome this limitation, we create mock implementations to simulate the behavior of the unavailable services during the development of the client service.
At the end, the project directory structure will appear as follows:
- Tools
The tools required In the host machine:
- Docker: Containerization tool
- VSCode: Code editing tool
- Dev containsers extension for VSCode
- Docker extension for VSCode
- Git
The tools and technologies that we will use Inside containers for each service:
- Wiremock service: Wiremock. We use this service to simulate our api-gateway service.
- Web-Client service:
- Typescript : programming language
- Next.js: Web framework that can be used for developing backend and front-end applications.
- Tailwind css: A utility-first CSS framework
- react-hook-form
- TanStack Query: A asynchronous state management.
- Docker dev environment
Development inside Docker containers can provide several benefits such as consistent environments, isolated dependencies, and improved collaboration. By using Docker, development workflows can be containerized and shared with team members, allowing for consistent deployments across different machines and platforms. Developers can easily switch between different versions of dependencies and libraries without worrying about conflicts.
When developing inside a Docker container, you only need to install Docker
, Visual Studio Code
, and the Dev Containers
and Docker
extensions on VS Code. Then you can run a container using Docker and map a host folder to a folder inside the container, then attach VSCode to the running container and start coding, and all changes will be reflected in the host folder. If you remove the images and containers, you can easily start again by recreating the container using the Dockerfile and copying the contents from the host folder to the container folder. However, it's important to note that in this case, any tools required inside the container will need to be downloaded again. Under the hood, When attaching VSCode to a running container, Visual Studio code install and run a special server inside the container which handle the sync of changes between the container and the host machine.
- API mock service: Wiremock
During the development of our microservice application, we have implemented various patterns to ensure efficient development. One of the patterns we have followed is service-per-team development. This approach focuses on each team developing their services independently, with limited knowledge of and no direct access to other services. When our service relies on another service that is inaccessible during development, we use mocking techniques to simulate the behavior of that service. For different service communication protocols such as gRPC, REST API, GraphQL, and others, we have various applications and even online services that do this job for us. In our current scenario, we aim to replicate the behavior of an API gateway service. To achieve this, we rely on the usage of Wiremock, a tool that offers a convenient solution for mocking APIs. By utilizing Wiremock, we can simulate the responses and behavior of the API gateway service, allowing us to continue development and testing seamlessly. Using Wiremock and similar tools, we can effectively emulate the behavior of external services, enabling smoother development and testing workflows within our microservice architecture. Running and configuring Wiremock via Docker is quite easy. Lets begin!
- Create a folder for the project and choose a name for it (such as 'microservice'). Then create a folder named
client-web
. This folder is the root directory of the current project. You can then open the root folder in VS Code by right-clicking on the folder and selecting 'Open with Code'.- Inside the root directory create a folder with the name
wiremock
, then create a Dockerfile and set content toFROM wiremock/wiremock:2.35.0
- Create a folder inside wiremock named mappings. Inside this folder we define our endpoints. For example lets say we have the following endpoint in our api-gateway:
Then using a json file containing a request and a corresponding response we can configure wiremock to mimic that end-point.
{
"request": {
"method": "GET",
"urlPattern": "/api/v1/ping"
},
"response": {
"status": 200,
"body": "{\"Message\": \"Pong\"}"
}
}
-During the development of the client service, our API gateway service is not accessible. However, the protocol layer models for each service have already been determined and made accessible through collaborative efforts and guidance from the Technical Leads. We use this models to mock our api-gateway service. Copy all json files from here to mapping folder.
- Create a file named .env in the root directory and add the following content:
WIREMOCK_PORT = 8088
WIREMOCK_CONTAINER_PORT=8080
CLIENT_PORT = 3000
- Inside root directory create a file named docker-compose.yml and add the following content.
version: '3'
services:
wiremock:
build:
context: ./wiremock
dockerfile: Dockerfile
container_name: wriremock
ports:
- ${WIREMOCK_PORT}:${WIREMOCK_CONTAINER_PORT}
volumes:
- ./wiremock/mappings:/home/wiremock/mappings
- Run
docker-compose up -d --build
. Now go tohttp://localhost:8088/__admin/
. You can see the available mock end points.
- you can test this service using applications like postman.
- Our api-gateway mock is ready! run
docker-compose down
- Client-web service: Typescript
Before delving into the specifics, let's begin by describing the application we are developing. The application consists of four backend services: authentication, job scheduler, and report services. These services are not directly accessed by the client application. Instead, an API gateway acts as a bridge, facilitating communication between the client and these services.
The client application offers the following functionalities:
- User Registration and Login: The application provides signup and login pages where users can create and authenticate their accounts.
- Admin Features: Administrative users have additional capabilities, including:
- Querying the List of Registered Users: Admins can retrieve information about the registered users.
- Querying the List of Scheduled Jobs: Admins have the ability to inquire about the jobs currently scheduled and edit or delete them.
- Scheduling New Jobs: Admins can create and schedule new jobs.
- Querying Reports: Admins can retrieve reports generated by the system.
Our Next.js application structure can be summarized as follow:
- A server that acts as a proxy between the api gateway and the client app. This server is optional and one benefit of it is Solving CORS problems as our api-gateway and web-client service may run on different domains. Also we can add an extra layer of security in this server to protect our-api gateway.
- Three main pages that all are rendered in the client side (inside browser). Signup, login and main page. the main page has three components. One for querying the users list, One for handling jobs and the final one for querying reports.
Create a folder named
client-service
insideclient-web
folder.Create a Dockerfile inside
client-service
and set the contents to
FROM node:20.4.0
WORKDIR /usr/src/app
- Add the following to the service part of our docker-compose.yml file.
client:
build:
context: ./client-service
dockerfile: Dockerfile
container_name: client
command: sleep infinity
ports:
- ${CLIENT_PORT}:${CLIENT_PORT}
environment:
- APIGATEWAY_URL=http://wriremock:${WIREMOCK_CONTAINER_PORT}
volumes:
- ./client-service:/usr/src/app
- We are going to do all the development inside a docker container without installing node.js in our host machine. To do so, we run the containers and then attach VSCode to the client-service container. As you may noticed, the Dockerfile for client-service has no entry-point therefore we set the command value of it to
sleep infinity
to keep the container awake.- Now run
docker-compose up -d --build
- While running, attach to the client service by clicking bottom-left icon and then select
attach to running container
. Select client service and wait for a new instance of VSCode to start. Upon starting the attached instance of VSCode, you will be prompted to open a folder within the container. As we have designated the WORKDIR as /usr/src/app inside the Dockerfile, we will select this folder inside the container. It is important to note that this designated folder is mounted to the client-service folder on the host machine using Docker Compose volumes. Consequently, any changes made within the selected folder will be automatically synced to this folder on the host machine. This synchronization ensures that modifications made during development are reflected in both the container and the host environment.- After opening the folder
/usr/src/app
, open a new terminal and initialize the next.js project by runningnpx create-next-app --typescript client
. You need to go through a list of question before initializing the project. Select the answers as shown below.
- After initializing the app, a file named packages.json is created. This file contains information such as dependencies, package name, version, etc. Another part of this file is the scripts.
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
- We can run this scripts from the client folder using
npm run <scriptname>
. Runcd client
and then runnpm run dev
. Go tohttp://localhost:3000
and you can see the default page of our project. stop the service by hittingctl + c
- We use newly introduced
app routing
in our project. To compare app-routing and page routing refer to next.js website and read the documentation by selecting them.
- We start by creating our server. This server will act as a proxy between our website and the api gateway. Create a folder named config inside src folder and then a file named index.tsx. set the content to
export const URL_APIGATEWAY=`${process.env.APIGATEWAY_URL}/api/v1`;
- In the app router routing method, You can create a folder XXX inside the app folder and then create a file named rout.tsx and a file named page.tsx inside. The route.tsx acts as your api end point and the page.tsx is a webpage at that route. Create a folder named api and then a folder inside named ping. create a file named route.tsx inside ping and set the content to:
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
return new NextResponse(JSON.stringify({ message: "Pong" }), {
status: 200,
})
}
- Run
npm run dev
. Now gohttp://localhost:3000/api/ping
. you can see the response sayingpong
!- Stop the server by hitting
ctl + c
- Create a file named middleware.tsx inside src folder and set the content to
import { NextRequest, NextResponse } from 'next/server'
import {URL_APIGATEWAY} from './config'
export async function middleware(req: NextRequest) {
const regex = new RegExp('/api/*')
if (!regex.test(req.url)) {
return new NextResponse(null, {
status: 400,
statusText: "Bad Request"
})
}
console.log("middleware is called for url: ",req.url)
const url = URL_APIGATEWAY + "/" + req.url.split("/api/")[1]
console.log("middleware sends the request to : ", url)
const res = await fetch( url, {
method:req.method,
headers: req.headers,
body: req.body
});
return res
}
export const config = {
matcher: '/api/:path*',
}
- This middleware runs only for requests to the '/api/:path*' path. When we request this api, it will get the response from the api-gateway.
- Run
npm run dev
. Now gohttp://localhost:3000/api/ping
. you can see the response sayingmessage: Pong.
. This time the result has been returned from the api-gateway (Our mock service).- Stop the service by hitting
ctl + c
- Create a folder named types inside src. Inside this folder we will define our models. Copy all the files from here to this folder.
- run
npm install @tanstack/react-query react-hook-form react-hot-toast
- Create a folder named components and then a folder named providers inside it. Here we are going to put the components that provide a capability for a context. Copy the files from here. One of the providers is AuthProvider. This component provides a context for the logged-in user to be used by any child elements inside the app.
'use client';
import {User} from '@/types'
import React from 'react';
const UserContext = React.createContext<
[User | null, React.Dispatch<React.SetStateAction<User | null>>] | undefined
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = React.useState<User | null>(null);
return (
<UserContext.Provider value={[user, setUser]}>
{children}
</UserContext.Provider>
);
}
export function useAuth() {
const context = React.useContext(UserContext);
if (context === undefined) {
throw new Error('useAuth must be used within a UserContext');
}
return context;
}
- We warp the whole pages in our application inside these providers. go to layout.tsx inside the app folder and change the file content to:
import './globals.css';
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { QueryProvider } from '@/components/providers/query_provider'
import {AuthProvider} from '@/components/providers/auth_provider'
import ToastProvider from '@/components/providers/toast_provider'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Microservices job scheduler',
description: 'A simple job scheduler app with microservices architecture.',
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={`${inter.className} font-inter antialiased bg-gray-300 text-gray-900 tracking-tight`}>
<ToastProvider />
<AuthProvider>
<QueryProvider>
{children}
</QueryProvider>
</AuthProvider>
</body>
</html>
)
}
Create a folder named client inside components folder and then a file named signup_form.tsx. Set the contents from here. Inside this file we use
useForm
for binding form content toCreateUserRequest
model anduseMutation
to perform user registration.Create a folder named
(auth)
(with parenthesis: this name will be ignored in the routing) and then a folder named signup. then create a file named page.tsx and set the content to
'use client'
import { useAuth } from "@/components/providers/auth_provider";
import SignUpForm from "@/components/client/signup_form";
import { redirect } from 'next/navigation'
export default function SignUp() {
const [user,_setUser] = useAuth()
if (user) {
redirect("/")
}
return (
<main>
<SignUpForm/>
</main>
);
}
- here we have used
useAuth()
to check if the user is already logged in or not. If yes, simply redirect to the homepage. Now runnpm run dev
and then go tohttp://localhost:3000/signup
. Fill in the form to match the mapping of our mock api (In this case name: admin,email:admin@admin.com,password: password,role: 0). Now hit signup. If everything goes according to plan, the sign up would be successful and you will be redirected to the login page which does not exist at the moment.
- Stop the server by hitting
ctl + c
- Create a file named login_form.tsx inside components/client folder and set the contents from here.
- Create a folder inside
(auth)
folder named login and then a file named page.tsx. Set the contents from here.Now runnpm run dev
and then go tohttp://localhost:3000/login
. Fill in the form to match the mapping of our mock api (in this case: email: admin@admin.com,password: password). If everything goes according to plan, the login would be successful and you will be redirected to the homepage.Stop the server by hitting
ctl + c
Create a folder named logout inside (auth) folder. Then a file named page.tsx. set the contents from here.
Create a folder named (default) inside the app folder and then move the page.tsx file from app folder to (default). This file would be our homepage. We first create neccessary components and then show them inside our home page.Create a file named header.tsx inside components/client. Set the contents from here. Inside this component, we will show some items based on the logging state of the user. If the user is logged in, we show user's email and logout button. if the user is not logged in, we show login and signup buttons.
Create a folder named lib inside src folder. Then a file named api_gateway.tsx. We define a function called
fetch_with_refresh_token
. This function checks the return code for calls to our protected end-points and if the result is unauthorized, then we refresh the access token. Set the contents to:
import { StatusCodes } from 'http-status-codes';
// we do fetch, If the result is unauthorized, Possibily our access token has exired! we simply refresh it.
export async function fetch_with_refresh_token(url: string, options?: RequestInit | undefined): Promise<Response> {
let result = await fetch(url,options)
console.log("fetch result status code and texts are: ",result.status, result.statusText)
if (result.status == StatusCodes.UNAUTHORIZED) {
//possibly expired access token.
console.log("Unauthorized. possibly access token has expired. lets refresh access token....")
result = await fetch('/api/user/refresh_token', {
method: "POST"
})
console.log("refresh token result arrived",result)
if (result.status == StatusCodes.OK) {
console.log("refresh token request is successfull. calling again.")
//we have accessed new access token in browser cookie. now request again with our new access token
result = await fetch(url,options)
return result
}else {
console.log("refresh token request was not successfull")
}
}
return result
}
- Change the contents of page.tsx inside (default) folder to
'use client'
import React, { useEffect, useState } from "react"
import { ParseUser } from "@/types";
import { useAuth } from "@/components/providers/auth_provider";
import Header from '@/components/client/header'
import {fetch_with_refresh_token} from '../../lib/api_gateway'
export default function Home() {
const [_user,setUser] = useAuth()
useEffect(() => {
// declare the data fetching function
const fetchData = async () => {
const userResponse = await fetch_with_refresh_token("/api/user/get");
const user = await ParseUser(userResponse)
console.log("Home.useEffect.fetchData: User is: ",user)
setUser(user)
}
fetchData()
.catch(console.error);
}, [setUser])
const [currentComponent, setCurrentComponent] = useState<string | null>(null);
return (
<div>
<Header/>
<div className="flex min-h-screen flex-row bg-gray-100 text-gray-800">
<main className="main -ml-48 flex flex-grow flex-col p-4 transition-all duration-150 ease-in md:ml-0">
Main
</main>
</div>
</div>
);
}
- Now run
npm run dev
. Then go tohttp://localhost:3000/
and you can see the header. You can now navigate through the pages. Go tohttp://localhost:3000/signup
. Enter credentials and you will be redirected to login page. Then fill in the form and after hitting login button you will be redirected to the home page. As you are logged in now, the email and the logout button will be shown on the header.
- Now it is time to create other components of our home page. Copy the remaining files from here to components/client folder. This files are components for user, job and report handlings. Set the content of page.tsx inside (default) folder from here.
- Run
npm run dev
. Go tohttp://localhost:3000/
and voila. Our web app is ready.
- To DO
- Add tests
- Add tracing using Jaeger
- Add monitoring and analysis using grafana
- Refactoring
I would love to hear your thoughts. Please comment on your opinions. If you found this helpful, let's stay connected on Twitter! xaledhosseini.
Top comments (0)