Add dynamic route matching and validation improvements

Introduces dynamic route parameter support in route path computation and schema matching. Adds a utility for sending validation errors, updates schema definitions to support header validation, and refactors server middleware to handle header, body, and query validation with improved error handling. Also adds a placeholder user route and updates dependencies to include 'path-to-regexp'.
This commit is contained in:
Gilles Lazures 2025-12-20 18:19:03 +01:00
parent 18efd445e5
commit 7e1eaf3f1f
8 changed files with 118 additions and 42 deletions

View File

@ -6,6 +6,8 @@ class DefaultError extends Error {
this.cause = cause || "Internal Server Error"
this.message = message || "Internal Server Error"
this.isOperational = true
Error.captureStackTrace(this, this.constructor)
}
serialize() {

View File

@ -28,20 +28,19 @@ function getRecursiveFiles(dir) {
function computeRoutePath(baseDir, filePath) {
const relativePath = path.relative(baseDir, filePath)
const normalizedPath = relativePath.replace(/\\/g, "/")
let route = "/" + relativePath.split(path.sep).join("/")
let route = "/" + normalizedPath
if (route.endsWith("index.js")) {
route = route.replace("index.js", "")
} else {
route = route.replace(".js", "")
if (route.endsWith(".js")) {
route = route.slice(0, -3)
}
if (route.endsWith("/index")) {
route = route.slice(0, -6)
}
route = route.replace(/\/{2,}/g, "/")
route = route.replace(/\[([^\]]+)\]/g, ":$1")
if (route.length > 1 && route.endsWith('/')) {
route = route.slice(0, -1)
if (route === "") {
return "/"
}
return route

30
modules/utils.js Normal file
View File

@ -0,0 +1,30 @@
const path = require("node:path")
const Logger = require("./logger")
const logger = Logger.createLogger(path.join(__dirname, ".."))
function sendValidationError(req, res, zodResult, type, path, errorConfig) {
const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress
logger.warn(`Validation failed for ${req.method.cyan} ${path.cyan.bold} (${type}) ` + `<IP:${ip}>`.bold, ["WEB", "yellow"])
const response = {
success: false,
message: errorConfig.message || `Validation failed in ${type}`,
errors: zodResult.error.issues.map(e => ({
field: e.path.join("."),
message: e.message
}))
}
if (errorConfig) {
const extras = { ...errorConfig }
delete extras.status
delete extras.message
Object.assign(response, extras)
}
return res.status(errorConfig.status || 400).json(response)
}
module.exports = {
sendValidationError
}

1
package-lock.json generated
View File

@ -12,6 +12,7 @@
"colors": "^1.4.0",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"path-to-regexp": "^8.3.0",
"zod": "^4.2.0"
},
"devDependencies": {

View File

@ -25,6 +25,7 @@
"colors": "^1.4.0",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"path-to-regexp": "^8.3.0",
"zod": "^4.2.0"
},
"devDependencies": {

8
routes/users/[id].js Normal file
View File

@ -0,0 +1,8 @@
const express = require("express")
const router = express.Router()
router.post("/", async (req, res, next) => {
})
module.exports = router

View File

@ -1,9 +1,14 @@
const z = require("zod")
module.exports = {
errorFormat: "default",
POST: {
zod: z.object({
headers: z.object({
authorization: z.string()
.startsWith("Bearer ", { message: "Token d'authentification manquant ou invalide." })
.optional(),
"content-type": z.string().regex(/application\/json/i).optional()
}),
body: z.object({
email: z.string()
.email({ message: "Invalid E-Mail format." })
.toLowerCase(),
@ -17,7 +22,7 @@ module.exports = {
}),
error: {
code: 422,
message: "Invalid body request"
message: "Invalid request data"
}
}
}

View File

@ -3,7 +3,10 @@ const app = express()
const path = require("node:path")
const Logger = require("./modules/logger")
const logger = Logger.createLogger(__dirname)
const utils = require("./modules/utils")
const loader = require("./modules/loader")
const DefaultError = require("./errors/DefaultError")
const path2regex = require("path-to-regexp")
const routes = loader.getRecursiveFiles(path.join(__dirname, "routes"))
const schemas = loader.getRecursiveFiles(path.join(__dirname, "schemas"))
@ -35,46 +38,69 @@ app.all(/.*/, (req, res, next) => {
currentPath = currentPath.slice(0, -1)
}
const schemaConfig = schemaRegistry[currentPath]
let schemaConfig = schemaRegistry[currentPath]
let matchedParams = {}
if (!schemaConfig) {
const registeredRoutes = Object.keys(schemaRegistry)
for (const routePattern of registeredRoutes) {
if (!routePattern.includes(":")) {
continue
}
const matcher = path2regex.match(routePattern, { decode: decodeURIComponent })
const result = matcher(currentPath)
if (result) {
schemaConfig = schemaRegistry[routePattern]
matchedParams = result.params
break
}
}
}
if (!schemaConfig || !schemaConfig[req.method]) {
return next()
}
const methodConfig = schemaConfig[req.method]
const zodSchema = methodConfig.zod || methodConfig
const errorConfig = methodConfig.error || { status: 400, message: "Validation Error" }
const dataToValidate = (req.method === "GET" || req.method === "DELETE") ? req.query : req.body
const result = zodSchema.safeParse(dataToValidate)
req.params = { ...req.params, ...matchedParams }
if (result.success) {
if (req.method === "GET" || req.method === "DELETE") {
const methodConfig = schemaConfig[req.method]
const errorConfig = methodConfig.error || { status: 400, message: "Validation Error" }
if (methodConfig.headers) {
const headerResult = methodConfig.headers.safeParse(req.headers)
if (!headerResult.success) {
return utils.sendValidationError(req, res, headerResult, "headers", currentPath, errorConfig)
}
}
let dataSchema = null
let dataToValidate = null
let validationType = "body"
if (req.method === "GET" || req.method === "DELETE") {
dataSchema = methodConfig.query
dataToValidate = req.query
validationType = "query"
} else {
dataSchema = methodConfig.body
dataToValidate = req.body
}
if (dataSchema) {
const result = dataSchema.safeParse(dataToValidate)
if (!result.success) {
return utils.sendValidationError(req, res, result, validationType, currentPath, errorConfig)
}
if (validationType === "query") {
req.query = result.data
} else {
req.body = result.data
}
return next()
}
const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress
logger.warn(`Validation failed for ${req.method.cyan} ${currentPath.cyan.bold} ` + `<IP:${ip}>`.bold, ["WEB", "yellow"])
const response = {
success: false,
message: errorConfig.message,
errors: result.error.issues.map(e => ({
field: e.path.join("."),
message: e.message
}))
}
if (methodConfig.error) {
const extras = { ...methodConfig.error }
delete extras.status
delete extras.message
Object.assign(response, extras)
}
return res.status(errorConfig.status || 400).json(response)
return next()
})
for (const route of routes) {
@ -96,6 +122,10 @@ for (const route of routes) {
}
}
app.all(/.*/, (req, res, next) => {
next(new DefaultError(404, `Can't find ${req.originalUrl} on this server!`, null, "NotFound"))
})
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500