generated from azures04/Base-REST-API
MVP
This commit is contained in:
parent
d51a4cbafc
commit
4295d6bca3
22
README.md
22
README.md
@ -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
|
|
||||||
```
|
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
routes/products/[productName]/[productPlatform]/download.js
Normal file
12
routes/products/[productName]/[productPlatform]/download.js
Normal 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
|
||||||
12
routes/products/[productName]/[productPlatform]/manifest.js
Normal file
12
routes/products/[productName]/[productPlatform]/manifest.js
Normal 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
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
server.js
22
server.js
@ -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") {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user