DEV Community

Cover image for Making APIs with Express & Typescript using OOPs
Sid
Sid

Posted on

Making APIs with Express & Typescript using OOPs

This blog doesn't cover concepts involving the technologies used to create the project. To know about the core technical concepts used in this learn from here: https://devxp.in/

  1. Before building an API, two key aspects need to be clarified: data modeling and API routes. Here's how we can proceed:
  2. Define Sample Data: Start by identifying sample data to understand the entities and their relationships.
  3. Create an ER Model: Use the sample data to design an Entity-Relationship (ER) model that visually represents the structure and relationships between different entities.
  4. Generate Prisma Schema: Convert the ER model into a Prisma schema to define your database structure programmatically.
  5. Seed the Database: Populate the PostgreSQL database with a substantial amount of sample data (to use Redis) using seeding scripts. These scripts, available in your GitHub repository, will ensure the database is ready for development and testing.

Schema

Now we need to make our Api routes and for that we will divide it into two parts: Main routes and sub routes

API flow logic

Now as we know the flow of our logic lets make an instance of our own custom server which will include an instance of express app, middle-wares, initialisation of our routes, and connection to Redis running on docker container and Postgres. Also we will create a custom class of CLIENT which holds are connection to Redis, database. (We can create more client connections if we want in CLIENT class)

 class Client {
    private readonly prisma: PrismaClient;
    private readonly redis: Redis;

    constructor() {
        this.prisma = new PrismaClient();
        this.redis = new Redis({
            host: process.env.REDIS_HOST,
            port: parseInt(process.env.REDIS_PORT || "6379"),
        });

        this.redis.on("connect", () => {
            console.log("Successfully connected to Redis! 🚀");
        });

        this.redis.on("error", (err) => {
            console.error("Redis connection error:", err);
        });
    }

    public Prisma() {
        return this.prisma;
    }

    public Redis() {
        return this.redis;
    }

    public async connectDB(): Promise<void> {
        try {
          await this.prisma.$connect(); 
          console.log("Successfully connected to database 🎯");
        } catch (error) {
          if (error instanceof Error) {
            console.error("Error connecting to database:", error.message);
          } else {
            console.error("An unknown error occurred while connecting to the database.");
          }
        }
      }
}

const client = new Client()
export default client
Enter fullscreen mode Exit fullscreen mode
class Server {
    private readonly app: Application;
    private readonly port: string | number;
    private readonly serverUrl: string;

    constructor() {
        this.app = express();
        this.port = process.env.PORT_NUMBER || 3000;
        this.serverUrl = process.env.SERVER || 'http://localhost';
        this.initializeMiddlewares();
        this.initializeRoutes();
    }

    private initializeMiddlewares(): void {
        this.app.use(express.json());
        this.app.use(express.urlencoded({ extended: true }));
        this.app.use(error_handling);
    }
    private initializeRoutes(): void {
        AllRoutes(this.app);
    }

    public async start(): Promise<void> {
        try {
            await client.connectDB();
            await client.Redis();
            this.app.listen(this.port, () => {
                console.log(`Server is running at: ${this.serverUrl} 🐳`);
            });
        } catch (error) {
            if (error instanceof Error) {
                console.error('Server startup failed:', error.message);
                process.exit(1);
            }
            console.error('An unknown error occurred during server startup');
            process.exit(1);
        }
    }
}

const server = new Server();
server.start();

Enter fullscreen mode Exit fullscreen mode

Now we have our Server instance now we need to setup AllRoutes function which is nothing but an instance of our Main Routes Class:


class MainRoutes {
  private app: Application;
  private readonly path: string;

  constructor(app: Application) {
    this.app = app;
    this.initializeRoutes();
    this.path = "/api/v3";
  }

  private initializeRoutes(): void {
    this.app.use("/", home);
    this.app.use(`${this.path}`, Api_user);
    this.app.use(`${this.path}/users`, users);
    this.app.use(`${this.path}/posts`, posts);
    this.app.use(`${this.path}/todos`, todos);
    this.app.use(`${this.path}/albums`, albums);
    this.app.use(`${this.path}/addresses`, addresses);
    this.app.use(`${this.path}/images`, images);
  }
}

export default (app: Application): void => {
  new MainRoutes(app);
};
Enter fullscreen mode Exit fullscreen mode

Now for the sub-routes CLASS (we will see Api_user route towards the end):

import { Router, Request, Response, NextFunction } from "express";

type HttpMethod = 'get' | 'post' | 'patch' | 'delete';

export class SubRoutes {
    private router: Router;

    constructor() {
        this.router = Router();
    }

    public endpoint(
        method: HttpMethod,
        path: string,
        handler: any,
        middleware: Array<any>
    ): void {
        this.router[method](
            path,
            ...middleware,
            async (req: Request, res: Response, next: NextFunction) => {
                try {
                    await handler(req, res);
                } catch (error ) {
                    next(error);
                }
            }
        );
    }

    public getRouter(): Router {
        return this.router;
    }
}

Enter fullscreen mode Exit fullscreen mode

And finally user route will be (& so will be the other routes):

const createUserRoutes = (): Router => {
  const prisma = client.Prisma()
  const auth = new JWT()
  const userRoutes = new SubRoutes();
  const user = new Data(prisma.user);

  userRoutes.endpoint("get", "/", user.getAll.bind(user), [auth.decryptJWT]);
  userRoutes.endpoint("get", "/:id", user.getOne.bind(user), [auth.decryptJWT]);
  userRoutes.endpoint("post", "/", user.Create.bind(user), [auth.decryptJWT]);
  userRoutes.endpoint("patch", "/:id", user.Update.bind(user), [auth.decryptJWT]);
  userRoutes.endpoint("delete", "/:id", user.Delete.bind(user), [auth.decryptJWT]);

  return userRoutes.getRouter();
};

const users = createUserRoutes();
export default users;

Enter fullscreen mode Exit fullscreen mode

Now we can see middleware ie auth.decryptJWT which is nothing but a method of another custom class JWT as to encapsulate two related functions ie generate a JWT token and then decrypt it to enable auth. Also we see middleware argument has an array type which means we can add more middle-wares if we want such as rate-limitter, logging etc.

export default class JWT {
  private readonly secretKey: string;
  private readonly maxAge: string;

  constructor() {
    if (!process.env.JWT_SECRET || !process.env.MAX_AGE) {
      throw new Error("Missing environment variables JWT_SECRET or MAX_AGE");
    }
    this.secretKey = process.env.JWT_SECRET;
    this.maxAge = process.env.MAX_AGE;
  }

  public createToken(id: number): Promise<string> {
    return new Promise<string>((resolve, reject) => {
      jwt.sign(
        { id },
        this.secretKey,
        {
          expiresIn: parseInt(this.maxAge, 10),
        },
        (err, token) => {
          if (err) {
            return reject(err);
          }
          if (!token) {
            return reject(new Error("Failed to create token"));
          }
          resolve(token);
        }
      );
    });
  }

  public decryptJWT = (req: Request, res: Response, next: NextFunction) => {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1]; // Extract the token

    if (!token) {
      return res.status(401).json({ message: 'Token is required' });
    }

    jwt.verify(token, this.secretKey, (err, decoded) => {
      if (err) {
        return res.status(403).json({ message: 'Invalid or expired token' });
      }
      req.user = decoded as JwtPayload;
      next();
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

Also we can see that the handler function is a method of the user object, which is an instance of the Data class. For each route (e.g., /todos, /images), a route-specific instance of the Data class is created. The Data class contains various methods that are executed when the user requests a specific route. These methods include CRUD operations and Redis caching to enhance performance. Please refer my Github for the code.

Now lets talk about Api_user route. So this route basically allows users to sign in & login to our API for them to access our other routes. So for that another class is created called USER. It includes methods for signup and login. Please note this class is independent of Data Class. Please refer my Github for the code.

const createUserRoutes = (): Router => {
  const APIuserRoutes = new SubRoutes();
  const APIuser = new User(client.Prisma());

  APIuserRoutes.endpoint("get", "/signup", APIuser.signupPage, []);
  APIuserRoutes.endpoint("post", "/signup", APIuser.signup, []);
  APIuserRoutes.endpoint("get", "/login", APIuser.loginPage, []);
  APIuserRoutes.endpoint("post", "/login", APIuser.login, []);

  return APIuserRoutes.getRouter();
};

const users = createUserRoutes();
export default users;
Enter fullscreen mode Exit fullscreen mode

Special note: All the response body is standardized for uniformity:

 private sendResponse<T>(
    res: Response,
    statusCode: number,
    message: string,
    data?: T,
    error?: string
  ): Response {
    const response: ResponseBody<T> = {
      status: statusCode >= 400 ? "error" : "success",
      message,
      data,
      error,
    };
    return res.status(statusCode).json(response);
  }
Enter fullscreen mode Exit fullscreen mode

We can enhance this simple API further by incorporating features like rate limiters, Prometheus monitoring, queues, pub/sub mechanisms, and more. These additions can be integrated similarly to ensure scalability and optimized performance.

Top comments (0)