This commit is contained in:
Gilles Lazures 2026-01-27 22:43:59 +01:00
parent d51a4cbafc
commit 4295d6bca3
8 changed files with 89 additions and 79 deletions

View File

@ -1,21 +1 @@
# Base-REST-API # Brick.WebService
A robust, modular, and secure REST API boilerplate built with **Node.js** and **Express**.
It features a **recursive file loader** for routes and schemas, along with a powerful validation middleware using **Zod**.
## 🚀 Features
- **Automated Loading**: Recursively loads routes and validation schemas from the file system.
- **Strict Validation**: Request bodies and query parameters are validated using [Zod](https://zod.dev/) before reaching the controller.
- **Clean Architecture**: Separation of concerns with `Routes` (HTTP layer), `Services` (Business logic), and `Schemas` (Validation).
- **Security First**: Inputs are stripped of unknown fields automatically.
- **Custom Logger**: Integrated color-coded logging system for development and file logging for production.
- **Error Handling**: Standardized JSON error responses.
## 📦 Installation
1. **Clone the repository**
```bash
git clone https://gitea.azures.fr/azures04/Base-REST-API.git
cd Base-REST-API
```

View File

@ -13,7 +13,9 @@ class DefaultError extends Error {
serialize() { serialize() {
return { return {
code: this.code, code: this.code,
message: this.message name: this.name,
cause: this.cause,
message: this.message,
} }
} }
} }

View File

@ -0,0 +1,12 @@
const express = require("express")
const productsFileService = require("../../../../services/productsFileService")
const router = express.Router({ mergeParams: true })
router.get(/.*/, async (req, res, next) => {
const { productName, productPlatform } = req.params
await productsFileService.canAccess(productName, productPlatform)
const file = await productsFileService.getFile(req.url, productName, productPlatform)
return res.status(200).download(file.path)
})
module.exports = router

View File

@ -0,0 +1,12 @@
const express = require("express")
const productsFileService = require("../../../../services/productsFileService")
const router = express.Router({ mergeParams: true })
router.get("/", async (req, res) => {
const { productName, productPlatform } = req.params
await productsFileService.canAccess(productName, productPlatform)
const files = await productsFileService.getProductFiles(productName, productPlatform)
return res.status(200).json(files)
})
module.exports = router

View File

@ -1,25 +0,0 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i).optional()
}),
body: z.object({
email: z.string()
.email({ message: "Invalid E-Mail format." })
.toLowerCase(),
username: z.string()
.min(3, { message: "The username must be at least 3 characters long." })
.max(16, { message: "The username must be no longer than 16 characters." }),
password: z.string()
.min(8, { message: "The password must be at least 8 characters long." })
.regex(/[A-Z]/, { message: "The password must contain a capital letter." })
.regex(/[0-9]/, { message: "The password must contain a number." }),
}),
error: {
code: 422,
message: "Invalid request data"
}
}
}

View File

@ -1,15 +0,0 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
authorization: z.string()
.startsWith("Bearer ", { message: "Token d'authentification manquant ou invalide." }),
"content-type": z.string().regex(/application\/json/i).optional()
}),
error: {
code: 422,
message: "Invalid request data"
}
}
}

View File

@ -72,12 +72,18 @@ app.all(/.*/, (req, res, next) => {
req.params = { ...req.params, ...matchedParams } req.params = { ...req.params, ...matchedParams }
const methodConfig = schemaConfig[req.method] const methodConfig = schemaConfig[req.method]
const errorConfig = methodConfig.error || { status: 400, message: "Validation Error" } const errorConfig = methodConfig.error || { code: 400, message: "Validation Error" }
const context = {
method: req.method,
path: req.originalUrl,
ip: req.headers["x-forwarded-for"] || req.socket.remoteAddress
}
if (methodConfig.headers) { if (methodConfig.headers) {
const headerResult = methodConfig.headers.safeParse(req.headers) const headerResult = methodConfig.headers.safeParse(req.headers)
if (!headerResult.success) { if (!headerResult.success) {
return utils.sendValidationError(req, res, headerResult, "headers", currentPath, errorConfig) throw new ValidationError(headerResult, errorConfig, context)
} }
} }
@ -97,7 +103,7 @@ app.all(/.*/, (req, res, next) => {
if (dataSchema) { if (dataSchema) {
const result = dataSchema.safeParse(dataToValidate) const result = dataSchema.safeParse(dataToValidate)
if (!result.success) { if (!result.success) {
return utils.sendValidationError(req, res, result, validationType, currentPath, errorConfig) throw new ValidationError(result, errorConfig, context)
} }
if (validationType === "query") { if (validationType === "query") {
req.query = result.data req.query = result.data
@ -117,13 +123,16 @@ for (const route of routes) {
for (const layer of router.stack) { for (const layer of router.stack) {
if (layer.route && layer.route.methods) { if (layer.route && layer.route.methods) {
const method = Object.keys(layer.route.methods).join(", ").toUpperCase() const method = Object.keys(layer.route.methods).join(", ").toUpperCase()
const subPath = routePath === "/" ? "" : routePath const innerPath = layer.route.path === "/" ? "" : layer.route.path
logger.log(`${method.cyan} ${subPath.cyan.bold} route registered`, ["WEB", "yellow"]) const mountPrefix = routePath === "/" ? "" : routePath
const fullDisplayPath = mountPrefix + innerPath
logger.log(`${method.cyan} ${fullDisplayPath.cyan.bold} route registered`, ["WEB", "yellow"])
} }
} }
} }
app.use(routePath, router) app.use(routePath, router)
} catch (error) { } catch (error) {
logger.error(route, ["WEB", "yellow"])
logger.error(error.toString(), ["WEB", "yellow"]) logger.error(error.toString(), ["WEB", "yellow"])
} }
} }
@ -133,8 +142,9 @@ app.all(/.*/, (req, res, next) => {
}) })
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500 const statusCode = err.statusCode || err.code || 500
logger.error(`Error occured on: ${req.originalUrl.bold}`, ["API", "red"])
logger.error(err.message, ["API", "red"]) logger.error(err.message, ["API", "red"])
if (typeof err.serialize === "function") { if (typeof err.serialize === "function") {

View File

@ -3,7 +3,16 @@ const path = require("node:path")
const crypto = require("node:crypto") const crypto = require("node:crypto")
const { DefaultError } = require("../errors/errors") const { DefaultError } = require("../errors/errors")
const { pipeline } = require("node:stream/promises") const { pipeline } = require("node:stream/promises")
const gameDataPath = path.join(process.cwd(), "data", "products") const productsDataPath = path.join(process.cwd(), "data", "products")
fs.promises.exists = async function exists(path) {
try {
await fs.promises.access(path)
return true
} catch {
return false
}
};
function normalizePath($path) { function normalizePath($path) {
return $path.split(path.win32.sep).join(path.posix.sep) return $path.split(path.win32.sep).join(path.posix.sep)
@ -21,13 +30,15 @@ async function getFileSha1(filePath) {
} }
} }
async function getGameFiles() { async function getProductFiles(productName, platform) {
const files = await fs.promises.readdir(gameDataPath, { recursive: true }) const finalPath = path.join(productsDataPath, productName, platform)
const gameFilesIndex = { root: [] } const files = await fs.promises.readdir(finalPath, { recursive: true })
const productFilesIndex = { root: [] }
for (const file of files) { for (const file of files) {
const filePath = path.join(gameDataPath, file) const filePath = path.join(finalPath, file)
const fileMetadata = await fs.promises.stat(filePath) const fileMetadata = await fs.promises.stat(filePath)
if (!fileMetadata.isDirectory()) {
if (!fileMetadata.isDirectory() && !filePath.endsWith(".brikcfg")) {
const normalizedFilePath = normalizePath(file) const normalizedFilePath = normalizePath(file)
const artifact = { const artifact = {
path: normalizedFilePath, path: normalizedFilePath,
@ -41,15 +52,15 @@ async function getGameFiles() {
}, },
name: path.parse(normalizedFilePath).base name: path.parse(normalizedFilePath).base
} }
gameFilesIndex.root.push(downloadObject) productFilesIndex.root.push(downloadObject)
} }
} }
return gameFilesIndex return productFilesIndex
} }
async function getFile(basePath) { async function getFile(basePath, productName, productPlatform) {
try { try {
const fixedPath = path.join(gameDataPath, decodeURI(basePath)) const fixedPath = path.join(productsDataPath, productName, productPlatform, decodeURI(basePath))
const fileMetadata = await fs.promises.stat(fixedPath) const fileMetadata = await fs.promises.stat(fixedPath)
if (fileMetadata.isDirectory()) { if (fileMetadata.isDirectory()) {
throw new DefaultError(409, "Can't download a directory", "", "NotDownloadableException") throw new DefaultError(409, "Can't download a directory", "", "NotDownloadableException")
@ -65,8 +76,31 @@ async function getFile(basePath) {
} }
} }
async function canAccess(productName, productPlatform) {
const fixedPath = path.join(productsDataPath, productName, productPlatform, ".brikcfg")
const fileState = await fs.promises.exists(fixedPath, fs.constants.F_OK)
if (!fileState) {
return { code: 200 }
}
try {
const brikConfig = JSON.parse(await fs.promises.readFile(fixedPath))
if (!brikConfig.canAccess) {
throw new DefaultError(403, "Forbidden", "Locked resource.", "InsuffisentPrivilegeException")
}
return { code: 200 }
} catch (error) {
if (!error instanceof DefaultError) {
throw new DefaultError(500, "Please contact the maintener", "CONFIGURATION", "0x00")
}
throw error
}
}
module.exports = { module.exports = {
getFile, getFile,
canAccess,
getFileSha1, getFileSha1,
getGameFiles getProductFiles
} }