- Lecture 16 Recap
- Middleware #1:
loggingmiddleware (Morgan) - Revise HTTP response
metadata - Proper HTTP
status codefor response - Vercel deployment
- Parameterized URL endpoint
- Middleware #2:
Endpoint not founddetection middleware - Middleware #3:
Invalid JSONdetection middleware - API versioning with Express
Router Data persistentwith file
Middlewares are functions that have access to the request object (req), the response object (res), and the next function in the application's request-response cycle. These functions can perform various tasks and modify the request and response objects before the request reaches its final route handler or before the response is sent back to the client.
In Lecture 16, we have been using express.json() which is a built-in middleware function in Express.js, available from version 4.16.0 onwards. It is used to parse incoming requests with JSON payloads.
In Lecture 17, we will use a few more middlewares to process requests before forwarding them to the route handlers (endpoints).
Morgan is an HTTP request logger middleware for node.js.
pnpm i morgan
pnpm i -D @types/morganThen import and use morgan by adding these lines of code.
import morgan from "morgan";
...
// add this line after the express.json() middleware
app.use(morgan("dev"));Now restart the API service (pnpm run dev) and try sending HTTP request to the API using Insomnia.
You should be able to see messages on the server side.
GET /students?program=CPE 200 4.087 ms - 369
POST /students 200 4.087 ms - 56
DELETE /students 200 4.087 ms - 453It is recommended to use consistent form of HTTP response. For example we may decide to use the following structure as standard form of response.
{
success: bool, // Is the operation successful?
message: string, // More description about the operation
data: JSON, // Data returned when successful
error: string // Error message from service
}Now we can revise those responses from previous lecture.
Currently, all responses have the HTTP status code of 200 OK which could be confusing in many situations.
It is recommended to use proper HTTP status code for different situations.
We can assign a status code for a reponse using the following code.
// DELETE (success)
return res.status(204).json({
success: true,
message: `Student ${body.studentId} has been deleted successfully`,
});
// DELETE (not found)
return res.status(404).json({
success: false,
message: "Student does not exists",
});
...Make sure that you have the tsconfig.json file as this example.
This ensures that when you build your project using pnpm run build, the output will be generated as JavaScript files (*.js) inside the dist folder. This is necessary for deploying in Vercel.
In the index.ts, add this line of code at the end.
// Export app for vercel deployment
export default app;After push your code in Github repository, you can deploy your project on Vercel.
During the deployment process you need to configure Framework settings manually as followed:
Framework Settings
- Framework Preset :
Express - Build Command :
npx tsc - Output Directory :
dist - Install Command :
pnpm install
Another setting that you may want to disable is the Deployment Protection.
Go to your [deployment-project] > Settings > Deployment Protection > Vercel Authentication and choose Disabled.
This will allow you to send HTTP request to your API without the need to login Vercel.
GET /students endpoint currently returns a collection of students (Student[]).
If we want the API to return information of a student we can implement in two different ways.
GET /students?studentId=670610123(usestudentIdas a query parameter) ... orGET /students/670610123(usestudentIdas a parameter in URL)
To this end, it is generally recommended to use the parameterized URL for this kind of purpose. To do this, we can use the following pattern of code.
// GET /students/{studentId}
app.get("/:studentId", (req: Request, res: Response) => {
try {
const studentId = req.params.studentId;
const result = zStudentId.safeParse(studentId);
// check validation result
// check if student with the studentId exists in DB
// return student data
...
} catch (err) {
// if an unexpected error occurs, return error message
...
}What happen if a client try to send HTTP request to non-existent endpoints, e.g., /teachers, /students/CPE, ...
Our API should be able to return proper JSON object with message to the client instead of return default error message.
We can create src/middlewares/notFoundMiddleware.ts with the following code.
import { type Request, type Response, type NextFunction } from "express";
const notFoundMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
res.status(404).json({
success: false,
message: "Endpoint not found",
});
};
export default notFoundMiddleware;This middleware will intercept all requests. If the request is sent to non-existent endpoint, then it will respond with the HTTP status code of 404 NOT FOUND and proper error message.
In order to use the notFoundMiddleware, we need to add this code in the index.ts right after the last route handler.
...
// all route handlers
...
// endpoint check middleware
router.use(notFoundMiddleware);What happen if a client send the following JSON object with a POST request to create a new student.
{
"studentId": "650615110",
"firstName": "Anutin"
"lastName": "Chaan Sa Morn",
"program": "CPE",
"programId": 101
}Our API would have problem parsing the JSON payload because of invalid format.
The process of parsing JSON payload is done by the express.json() middleware which processes the request before the route handler.
Therefore we can not handle this problem with route handler code.
The solution is to create another middleware that should be executed after the express.json() but before all route handlers.
Let's create src/middlewares/invalidJsonMiddleware.ts file with this code.
import type { error } from "console";
import { type Request, type Response, type NextFunction } from "express";
// Define a custom interface for the error object if needed
interface CustomError extends Error {
status?: number;
type?: string;
}
const errorHandler = (
err: CustomError,
req: Request,
res: Response,
next: NextFunction
) => {
if (
err instanceof SyntaxError &&
err.status === 400 &&
err.type === "entity.parse.failed"
) {
// Handle the JSON parse error specifically
console.error("Bad JSON syntax:", err.message);
return res.status(400).json({
success: false,
message: "Invalid JSON payload",
});
}
// Pass other errors to the default Express error handler or another custom handler
next(err);
};
export default errorHandler;Now we can use the middleware by adding this code in index.ts.
...
// body parser middleware
app.use(express.json());
// logger middleware
app.use(morgan("dev"));
// app.use(morgan("combined"));
// JSON parser middleware
app.use(invalidJsonMiddleware);
// Endpoints
app.get("/", (req: Request, res: Response) => {
res.send("API services for Student Data");
});
...Now try sending HTTP POST request with invalid JSON format again and see what will happen.
API requirements usually evolve over time. How can we modify the API without making problem with our current clients.
The answer is using API Versioning. This way current clients can keep using the current version (v1) and we can start developing a newer version (v2) for future clients.
To do this we can use Express to create a Router and map an endpoint with version to the router.
Let's create src/routes/studentsRoutes_v1.ts and move the code for route handlers from index.ts into this new file.
// src/routes/studentsRoutes_v1.ts
import { Router, type Request, type Response } from "express";
import {
zStudentDeleteBody,
zStudentPostBody,
zStudentPutBody,
zStudentId,
} from "../libs/studentValidator.js";
import type { Student } from "../libs/types.js";
// import database
import { students } from "../db/db.js";
// import endpoint not found middleware
import notFoundMiddleware from "../middlewares/notFoundMiddleware.js";
// create a new router
const router = Router();
// GET /api/v1/students
// get students (by program)
router.get("/", (req: Request, res: Response) => {...});
// GET /api/v1/students/{studentId}
router.get("/:studentId", (req: Request, res: Response) => {...});
// POST /api/v1/students, body = {new student data}
// add a new student
router.post("/", async (req: Request, res: Response) => {...});
// PUT /api/v1/students, body = {studentId}
// Update specified student
router.put("/", (req: Request, res: Response) => {...});
// DELETE /api/v1/students, body = {studentId}
// delete specified student
router.delete("/", (req: Request, res: Response) => {...});
// use endpoint check middleware
router.use(notFoundMiddleware);
// Do not forget to export the router
export default routerIf we want to provide these services as /api/v1/students, we can use the following code in index.ts.
// index.ts
// import the new route
import studentRouter from "./routes/studentsRoutes_v1.js";
// mapping new endpoint to the new route
app.use("/api/v1/students", studentRouter);
...After this, we should be able to send HTTP request to the following URL:
http://localhost:3000/api/v1/students
The current version stores all data inside a variable, students (src/db/db.ts). This variable will be reset when our API restarts.
We can make our data persistent by storing data in database or file.
Let's store our students in the src/db/db_students.json.
[
{
"studentId": "650610001",
"firstName": "Matt",
"lastName": "Damon",
"program": "CPE",
"programId": 101
},
{
"studentId": "650610002",
"firstName": "Cillian",
"lastName": "Murphy",
"program": "CPE",
"programId": 101,
"courses": [
261207,
261497
]
},
{
"studentId": "650610003",
"firstName": "Emily",
"lastName": "Blunt",
"program": "ISNE",
"programId": 102,
"courses": [
269101,
261497
]
},
{
"studentId": "650615015",
"firstName": "Lisa",
"lastName": "Na baan yuan",
"program": "ISNE",
"programId": 102
}
]Now we create src/db/db_transactions.ts file using this code.
This file contains code that defines functions for read and write JSON file.
import * as fs from "fs/promises"; // For promise-based fs methods
import { type Student } from "../libs/types.js";
import { fileURLToPath } from "url";
import { dirname } from "path";
// Define __dirname for ES module scope
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DATA_FILE_PATH = `${__dirname}/db_students.json`;
export async function readDataFile(): Promise<Student[]> {
try {
console.log(DATA_FILE_PATH);
const data = await fs.readFile(DATA_FILE_PATH, {
encoding: "utf8",
});
return JSON.parse(data) as Student[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
console.warn("Data file not found, returning empty array.");
return []; // Return empty array if file doesn't exist
}
console.error("Error reading data file:", error);
throw error;
}
}
export async function writeDataFile(data: Student[]): Promise<void> {
try {
const jsonString = JSON.stringify(data, null, 2); // null, 2 for pretty printing
await fs.writeFile(DATA_FILE_PATH, jsonString, { encoding: "utf8" });
} catch (error) {
console.error("Error writing data file:", error);
throw error;
}
}Now we can create another Router for /api/v2/students in the file src/routes/studentsRoutes_v2.ts.
We can start by copying code from src/routes/studentsRouters_v1.ts. After that we will modify the code to follow the processes below.
The overview process of read and write JSON file are as followed.
GET /api/v2/students- Read JSON file into a variable
- Return the variable as respone
POST /api/v2/students- Read JSON file into a variable
- Add new student to the variable
- Write the updated variable to the JSON file
PUT /api/v2/students- Read JSON file into a variable
- Update a student data (specified by studentId)
- Write the update variable to the JSON file
DELETE /api/v2/students- Read JSON file into a variable
- Delete a student data (spcified by studentId)
- Write the update variable to the JSON file
For example, the code for GET /api/v2/students can be as followed.
...
// GET /api/v2/students
// get students (by program)
router.get("/", async (req: Request, res: Response) => {
try {
const students = await readDataFile();
const program = req.query.program;
if (program) {
let filtered_students = students.filter(
(student) => student.program === program
);
return res.json({
success: true,
data: filtered_students,
});
} else {
return res.json({
success: true,
data: students,
});
}
} catch (err) {
return res.json({
success: false,
message: "Something is wrong, please try again",
error: err,
});
}
});
...
Now you can modify the rest by yourself.
