Express en US - rocambille/start-express-react GitHub Wiki
Express is a minimalist and versatile web framework for server-side development. Here's a sample code sample to gauge your comfort level with the tool:
import express from "express";
const app = express();
const port = 5173;
app.get("/api", (req, res) => {
res.send("hello, world!");
});
app.listen(port, () => {
console.info(`Listening on http://localhost:${port}`);
});
If you see the "hello, world!" message returned in an HTTP response, you've mastered the basics of the framework.
Otherwise, we recommend that you review the Express documentation before going any further. In particular, make sure you are comfortable with routing in Express.
In the proposed base code, the creation of the StartER one server uses the routes defined in the src/express/routes.ts
file.
const app = express();
// ...
app.use((await import("./src/express/routes")).default);
// ...
The src/express/routes.ts
file declares and exports an instance of express.Router
.
import { Router } from "express";
const router = Router();
// ...
export default router;
You can define routes with this router
mini-app as if it were the main application.
A "simple" Express route combines an HTTP method (get, post, etc.), a URL path, and a callback function.
router.get("/api", (req, res) => { /* ... */ });
/* β β β
method path callback
*/
The (req, res) => { /* ... */ }
callback is a function that accesses the request object (req
) and the response object (res
) in the request lifecycle of the application. This function can:
- Execute code,
- Make changes to the request and response objects,
- End the request lifecycle.
For example, to end the cycle by sending a "hello, world!" response:
import { Router } from "express";
const router = Router();
/* ************************************************************************ */
router.get("/api", (req, res) => {
res.send("hello, world!");
});
/* ************************************************************************ */
export default router;
Method | Path | Callback |
router. get ("/api",(req, res) => { res.send("hello, world!"); }); |
router.get( "/api" ,(req, res) => { res.send("hello, world!"); }); |
router.get("/api", (req, res) => { res.send("hello, world!"); } ); |
In this example, the callback is inserted directly into the route declaration.
Alternatively, you can separate the callback declaration from the route declaration.
import { type RequestHandler, Router } from "express";
const router = Router();
/* ************************************************************************ */
// Callback declaration
const sendHelloWorld: RequestHandler = (req, res) => {
res.send("hello, world!");
};
// Route declaration
router.get("/api", sendHelloWorld);
/* ************************************************************************ */
export default router;
Separating the declarations allows you to move the callback code into separate files, and keep only the route declarations in the routes.ts
file: one file = one responsibility.
import { type RequestHandler, Router } from "express";
const router = Router();
/* ************************************************************************ */
// Callback import
import { sendHelloWorld } from "./path/to/module";
// Route declaration
router.get("/api", sendHelloWorld);
/* ************************************************************************ */
export default router;
This practice is the foundation of building modules in the Express section of StartER.
Express is a minimalist framework that doesn't impose any particular file organization. In StartER, we've chosen to frame the Express section with a modular architecture.
Modular architecture aims to create distinct parts of an application with isolated functionalities. In StartER, our criterion for isolating functionalities is to group them by "theme."
Most often, a theme corresponds to a resource served by the web API. For example, let's recreate a module for an item
resource (in this example, an item is a dummy object with a title: the item
module is provided by default in the StartER code). The first step is to create an item
folder in the src/express/modules
folder. The second step is to create a routes "sub-file" in our item
folder:
src/express
βββ routes.ts
βββ modules
βββ item
βββ itemRoutes.ts
The itemRoutes.ts
file is the entry point for the item
module: it must declare and export an isolated router for the "items" routes.
import { Router } from "express";
const itemRoutes = Router();
// ...
export default itemRoutes;
You can then use this item
module in src/express/routes.ts
:
// ...
import itemRoutes from "./modules/item/itemRoutes";
router.use(itemRoutes);
// ...
At this point, the item
module will build itself "freely" by adding whatever is needed to the item routes. After a few files, this could result in an item
folder like this:
src/express
βββ routes.ts
βββ modules
βββ item
βββ itemActions.ts
βββ itemParamConverter.ts
βββ itemRepository.ts
βββ itemRoutes.ts
βββ itemValidator.ts
The "free" approach depends on your own practices and habits, and one module doesn't have to look like another. Some habits that may justify adding a file to a module (non-exhaustive list):
- One file = one responsibility. This is the basic principle that will guide your choices.
- Isolate reusable functionality between multiple modules.
- Repeat a pattern for your modules.
Do modules until you find an organization that resonates with you.
Trust your logic. Give a chance to logics that are not yours!
To share our logic with you, when we built our item
module, we created a first file alongside itemRoutes.ts
: an itemActions.ts
file to declare the callbacks used by the item routes.
const browse = (req, res) => {
const items = /* we will tackle this next */;
res.json(items);
};
export default { browse };
The browse
action is an importable callback that can be used in itemRoutes.ts
:
import { Router } from "express";
const itemRoutes = Router();
/* ************************************************************************ */
import itemActions from "./modules/item/itemActions";
itemRoutes.get("/api/items", itemActions.browse);
/* ************************************************************************ */
export default itemRoutes;
A TypeScript word: To enable type inference on req
and res
objects, we recommend typing your action with Express's RequestHandler
type.
import type { RequestHandler } from "express";
const browse: RequestHandler = (req, res) => {
const items = /* we will tackle this next */;
res.json(items);
};
export default { browse };
To continue building the item
module, we need to complete the browse
action seen previously with "item" data.
const browse = (req, res) => {
const items = /* time to get data */;
res.json(items);
};
A first "basic" approach would be to write the data directly into browse
.
const browse = (req, res) => {
const items = [
{ id: 1, title: "Stuff" },
{ id: 2, title: "Doodads" },
];
res.json(items);
};
This can be a step in your prototyping. Our logic is that data access should be isolated from actions: one file = one responsibility.
This is why we created an itemRepository.ts
file in the src/express/modules/item
folder to contain the data access layer for our items. The word "repository" is commonly used in this context. According to Symfony documentation:
When you query for a particular type of object, you always use what's known as its "repository". You can think of a repository as a [...] class whose only job is to help you fetch entities of a certain class..
To vary the writing styles, we took this excerpt literally and built a class in the itemRepository.ts
file.
class ItemRepository {
// ...
}
export default new ItemRepository();
In this class, we will provide methods to retrieve and modify item data. A first method to retrieve all items:
class ItemRepository {
readAll() {
return [
{ id: 1, title: "Stuff" },
{ id: 2, title: "Doodads" },
];
}
}
export default new ItemRepository();
We can now use our repository in the item
module:
import itemRepository from "./itemRepository";
const browse = (req, res) => {
const items = itemRepository.readAll();
res.json(items);
};
export default { browse };
All item actions and the complete repository (linked to the database) are available in the following files:
See also:
In StartER, we use Express's "param converter" mechanism to transform URL parameters into entity objects. This approach has two advantages:
- Invalid IDs are detected immediately.
- No need to load the entity in each route.
The itemParamConverter.ts
file shows how to implement this functionality:
import type { RequestHandler } from "express";
import itemRepository from "./itemRepository";
const convert: RequestHandler = (req, res, next) => {
const item = itemRepository.read(req.params.itemId);
if (item == null) {
res.sendStatus(404);
} else {
req.item = item; // Store item in req to use it next
next();
}
};
export default { convert };
A TypeScript word: The base Request
type in Express does not define typing for custom properties like req.item
. To complete typing with your properties, we recommend extending Express's Request
type using declaration merging (see the manual).
import type { RequestHandler } from "express";
import itemRepository from "./itemRepository";
/* ************************************************************************ */
declare global {
namespace Express {
interface Request {
item: Item; // Extends Request interface of Express
}
}
}
/* ************************************************************************ */
const convert: RequestHandler = (req, res, next) => {
// ...
};
export default { convert };
To use the converter, we associate it with the itemId
parameter in itemRoutes.ts
:
import itemParamConverter from "./itemParamConverter";
itemRoutes.param("itemId", itemParamConverter.convert);
Then, any route containing :itemId
in its path will automatically trigger the converter. For example:
import itemParamConverter from "./itemParamConverter";
itemRoutes.param("itemId", itemParamConverter.convert);
/* ************************************************************************ */
itemRoutes.get("/api/items/:itemId", itemActions.read);
/* ************************************************************************ */
The read
action in itemActions.ts
can then directly access the item
object without worrying about retrieval or validation:
const read: RequestHandler = (req, res) => {
res.json(req.item); // item is fetched and valid
};
Data validation is crucial for any robust web application. In StartER, we use zod for input validation.
The itemValidator.ts
file illustrates our approach:
import type { RequestHandler } from "express";
import { type ZodError, z } from "zod";
/* ************************************************************************ */
// Validation schema
const itemDTOSchema = z.object({
id: z.number().optional(),
title: z.string().max(255),
user_id: z.number(),
});
/* ************************************************************************ */
// Validation middleware
const validate: RequestHandler = (req, res, next) => {
try {
// Valider et transformer les donnΓ©es d'entrΓ©e
req.body = itemDTOSchema.parse({
...req.body,
user_id: Number(req.auth.sub),
});
next();
} catch (err) {
res.status(400).json((err as ZodError).issues);
}
};
/* ************************************************************************ */
export default { validate };
The validate
middleware is then used in routes that require validation:
import itemValidator from "./itemValidator";
itemRoutes.post(
"/api/items",
itemValidator.validate, itemActions.add
);
itemRoutes.put(
"/api/items/:itemId",
itemValidator.validate, itemActions.edit
);