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:
parent
18efd445e5
commit
7e1eaf3f1f
@ -6,6 +6,8 @@ class DefaultError extends Error {
|
|||||||
this.cause = cause || "Internal Server Error"
|
this.cause = cause || "Internal Server Error"
|
||||||
this.message = message || "Internal Server Error"
|
this.message = message || "Internal Server Error"
|
||||||
this.isOperational = true
|
this.isOperational = true
|
||||||
|
|
||||||
|
Error.captureStackTrace(this, this.constructor)
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize() {
|
serialize() {
|
||||||
|
|||||||
@ -28,20 +28,19 @@ function getRecursiveFiles(dir) {
|
|||||||
|
|
||||||
function computeRoutePath(baseDir, filePath) {
|
function computeRoutePath(baseDir, filePath) {
|
||||||
const relativePath = path.relative(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(".js")) {
|
||||||
|
route = route.slice(0, -3)
|
||||||
if (route.endsWith("index.js")) {
|
}
|
||||||
route = route.replace("index.js", "")
|
if (route.endsWith("/index")) {
|
||||||
} else {
|
route = route.slice(0, -6)
|
||||||
route = route.replace(".js", "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
route = route.replace(/\/{2,}/g, "/")
|
route = route.replace(/\[([^\]]+)\]/g, ":$1")
|
||||||
|
|
||||||
if (route.length > 1 && route.endsWith('/')) {
|
if (route === "") {
|
||||||
route = route.slice(0, -1)
|
return "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
return route
|
return route
|
||||||
|
|||||||
30
modules/utils.js
Normal file
30
modules/utils.js
Normal 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
1
package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"path-to-regexp": "^8.3.0",
|
||||||
"zod": "^4.2.0"
|
"zod": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"path-to-regexp": "^8.3.0",
|
||||||
"zod": "^4.2.0"
|
"zod": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
8
routes/users/[id].js
Normal file
8
routes/users/[id].js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const express = require("express")
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.post("/", async (req, res, next) => {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@ -1,9 +1,14 @@
|
|||||||
const z = require("zod")
|
const z = require("zod")
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
errorFormat: "default",
|
|
||||||
POST: {
|
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: z.string()
|
||||||
.email({ message: "Invalid E-Mail format." })
|
.email({ message: "Invalid E-Mail format." })
|
||||||
.toLowerCase(),
|
.toLowerCase(),
|
||||||
@ -17,7 +22,7 @@ module.exports = {
|
|||||||
}),
|
}),
|
||||||
error: {
|
error: {
|
||||||
code: 422,
|
code: 422,
|
||||||
message: "Invalid body request"
|
message: "Invalid request data"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
88
server.js
88
server.js
@ -3,7 +3,10 @@ const app = express()
|
|||||||
const path = require("node:path")
|
const path = require("node:path")
|
||||||
const Logger = require("./modules/logger")
|
const Logger = require("./modules/logger")
|
||||||
const logger = Logger.createLogger(__dirname)
|
const logger = Logger.createLogger(__dirname)
|
||||||
|
const utils = require("./modules/utils")
|
||||||
const loader = require("./modules/loader")
|
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 routes = loader.getRecursiveFiles(path.join(__dirname, "routes"))
|
||||||
const schemas = loader.getRecursiveFiles(path.join(__dirname, "schemas"))
|
const schemas = loader.getRecursiveFiles(path.join(__dirname, "schemas"))
|
||||||
@ -35,46 +38,69 @@ app.all(/.*/, (req, res, next) => {
|
|||||||
currentPath = currentPath.slice(0, -1)
|
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]) {
|
if (!schemaConfig || !schemaConfig[req.method]) {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodConfig = schemaConfig[req.method]
|
req.params = { ...req.params, ...matchedParams }
|
||||||
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)
|
|
||||||
|
|
||||||
if (result.success) {
|
const methodConfig = schemaConfig[req.method]
|
||||||
if (req.method === "GET" || req.method === "DELETE") {
|
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
|
req.query = result.data
|
||||||
} else {
|
} else {
|
||||||
req.body = result.data
|
req.body = result.data
|
||||||
}
|
}
|
||||||
return next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress
|
return next()
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const route of routes) {
|
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) => {
|
app.use((err, req, res, next) => {
|
||||||
const statusCode = err.statusCode || 500
|
const statusCode = err.statusCode || 500
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user