kapi-mvc-blank 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import http from 'http';
3
+
4
+ function testEndpoint(path) {
5
+ return new Promise((resolve) => {
6
+ const options = {
7
+ hostname: 'localhost',
8
+ port: 3000,
9
+ path,
10
+ method: 'GET'
11
+ };
12
+
13
+ const req = http.request(options, (res) => {
14
+ let data = '';
15
+
16
+ res.on('data', (chunk) => {
17
+ data += chunk;
18
+ });
19
+
20
+ res.on('end', () => {
21
+ try {
22
+ const parsed = JSON.parse(data);
23
+ resolve(parsed);
24
+ } catch {
25
+ resolve(data);
26
+ }
27
+ });
28
+ });
29
+
30
+ req.on('error', (error) => {
31
+ resolve({ error: error.message });
32
+ });
33
+
34
+ req.end();
35
+ });
36
+ }
37
+
38
+ console.log('🧪 Testing API v1 vs v2 comparison...\n');
39
+
40
+ // Test v1
41
+ console.log('📊 API v1 Response (/api/v1/users/info):');
42
+ const v1Info = await testEndpoint('/api/v1/users/info');
43
+ console.log(JSON.stringify(v1Info, null, 2));
44
+
45
+ // Test v2
46
+ console.log('\n📊 API v2 Response (/api/v2/users/info):');
47
+ const v2Info = await testEndpoint('/api/v2/users/info');
48
+ console.log(JSON.stringify(v2Info, null, 2));
49
+
50
+ console.log('\n✅ Comparison completed!');
51
+ console.log('✓ v2 includes: api_version, os, arch, hostname');
52
+ process.exit(0);
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "kapi-mvc-blank",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node src/server.js",
8
+ "dev": "nodemon src/server.js",
9
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
10
+ "test:watch": "jest --watchAll"
11
+ },
12
+ "keywords": [
13
+ "api",
14
+ "node",
15
+ "prisma",
16
+ "mvc"
17
+ ],
18
+ "author": "@kferrandonFulbert",
19
+ "license": "ISC",
20
+ "description": "Framework MVC pour démarrer rapidement un projet d'API avec Node.js et Prisma (avec exemples d'authentification, upload de fichiers, documentation Swagger, logs avec Pino, etc.)",
21
+ "dependencies": {
22
+ "@prisma/adapter-mariadb": "^7.2.0",
23
+ "@prisma/client": "^7.2.0",
24
+ "bcryptjs": "^3.0.3",
25
+ "cors": "^2.8.5",
26
+ "dotenv": "^17.2.3",
27
+ "express": "^5.2.1",
28
+ "express-rate-limit": "^8.2.1",
29
+ "jsonwebtoken": "^9.0.3",
30
+ "multer": "^1.4.5-lts.1",
31
+ "pino": "^10.1.0",
32
+ "pino-http": "^11.0.0",
33
+ "rad-api": "^1.0.3",
34
+ "swagger-jsdoc": "^6.2.8",
35
+ "swagger-ui-express": "^4.6.3"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^25.0.8",
39
+ "jest": "^30.2.0",
40
+ "nodemon": "^3.1.11",
41
+ "prisma": "^7.2.0",
42
+ "supertest": "^7.2.2"
43
+ }
44
+ }
@@ -0,0 +1,23 @@
1
+ -- CreateTable
2
+ CREATE TABLE `User` (
3
+ `id` INTEGER NOT NULL AUTO_INCREMENT,
4
+ `email` VARCHAR(191) NOT NULL,
5
+ `name` VARCHAR(191) NULL,
6
+
7
+ UNIQUE INDEX `User_email_key`(`email`),
8
+ PRIMARY KEY (`id`)
9
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
10
+
11
+ -- CreateTable
12
+ CREATE TABLE `Post` (
13
+ `id` INTEGER NOT NULL AUTO_INCREMENT,
14
+ `title` VARCHAR(191) NOT NULL,
15
+ `content` VARCHAR(191) NULL,
16
+ `published` BOOLEAN NOT NULL DEFAULT false,
17
+ `authorId` INTEGER NOT NULL,
18
+
19
+ PRIMARY KEY (`id`)
20
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
21
+
22
+ -- AddForeignKey
23
+ ALTER TABLE `Post` ADD CONSTRAINT `Post_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (e.g., Git)
3
+ provider = "mysql"
@@ -0,0 +1,25 @@
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ output = "../generated/prisma"
4
+ }
5
+
6
+ datasource db {
7
+ provider = "mysql"
8
+ }
9
+
10
+
11
+ model User {
12
+ id Int @id @default(autoincrement())
13
+ email String @unique
14
+ name String?
15
+ posts Post[]
16
+ }
17
+
18
+ model Post {
19
+ id Int @id @default(autoincrement())
20
+ title String
21
+ content String?
22
+ published Boolean @default(false)
23
+ author User @relation(fields: [authorId], references: [id])
24
+ authorId Int
25
+ }
@@ -0,0 +1,14 @@
1
+ // This file was generated by Prisma, and assumes you have installed the following:
2
+ // npm install --save-dev prisma dotenv
3
+ import "dotenv/config";
4
+ import { defineConfig } from "prisma/config";
5
+
6
+ export default defineConfig({
7
+ schema: "prisma/schema.prisma",
8
+ migrations: {
9
+ path: "prisma/migrations",
10
+ },
11
+ datasource: {
12
+ url: process.env["DATABASE_URL"],
13
+ },
14
+ });
@@ -0,0 +1,16 @@
1
+ import request from 'supertest';
2
+ import app, { initializeRoutes } from '../app.js';
3
+
4
+ describe('User Controller', () => {
5
+ beforeAll(async () => {
6
+ await initializeRoutes();
7
+ });
8
+
9
+ test('GET /api/v1/users/info returns 200 and msg', async () => {
10
+ const res = await request(app)
11
+ .get('/api/v1/users/info')
12
+ .expect(200);
13
+
14
+ expect(res.body).toEqual({ msg: 'ok' });
15
+ });
16
+ });
package/src/app.js ADDED
@@ -0,0 +1,121 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import dotenv from 'dotenv';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname } from 'node:path';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { pinoMiddleware } from './utils/logger.js';
9
+ import { rateLimit } from 'express-rate-limit'
10
+ import swaggerUi from 'swagger-ui-express';
11
+ import swaggerSpec from './config/swagger.js';
12
+ import { initializePrisma } from './services/prisma.service.js';
13
+
14
+ dotenv.config();
15
+
16
+ // Initialize Prisma globally
17
+ initializePrisma();
18
+
19
+ const app = express();
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+
23
+ const UPLOAD_DIR = process.env.UPLOAD_DIR || 'uploads';
24
+
25
+ const limiter = rateLimit({
26
+ windowMs: 15 * 60 * 1000, // 15 minutes
27
+ limit: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
28
+ standardHeaders: 'draft-8', // draft-6: `RateLimit-*` headers; draft-7 & draft-8: combined `RateLimit` header
29
+ legacyHeaders: false, // Disable the `X-RateLimit-*` headers.
30
+ ipv6Subnet: 56, // Set to 60 or 64 to be less aggressive, or 52 or 48 to be more aggressive
31
+ })
32
+ app.use(limiter);
33
+
34
+ app.use(cors());
35
+ app.use(pinoMiddleware);
36
+ app.use(express.json());
37
+
38
+ // Serve uploaded files statically
39
+ app.use(`/${UPLOAD_DIR}`, express.static(UPLOAD_DIR));
40
+
41
+ // Swagger UI
42
+ app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
43
+ app.get('/docs.json', (req, res) => res.json(swaggerSpec));
44
+
45
+ // Export function to initialize routes
46
+ export async function initializeRoutes() {
47
+ const versionsDir = path.join(__dirname, './');
48
+ console.log(versionsDir);
49
+ const versionDirs = fs.readdirSync(versionsDir).filter(file => {
50
+ const fullPath = path.join(versionsDir, file);
51
+ return fs.statSync(fullPath).isDirectory() && /^v\d+$/.test(file);
52
+ });
53
+
54
+
55
+ // Sort versions numerically (v1, v2, v3, etc.)
56
+ versionDirs.sort((a, b) => {
57
+ const numA = Number.parseInt(a.slice(1), 10);
58
+ const numB = Number.parseInt(b.slice(1), 10);
59
+ return numA - numB;
60
+ });
61
+
62
+ // Load routes for each version
63
+ for (const version of versionDirs) {
64
+ const versionPath = path.join(__dirname, version);
65
+ const routesPath = path.join(versionPath, 'routes');
66
+
67
+ if (fs.existsSync(routesPath)) {
68
+ const routeFiles = fs.readdirSync(routesPath).filter(f => f.endsWith('.routes.js'));
69
+ console.log(routeFiles);
70
+ for (const routeFile of routeFiles) {
71
+ let routeName = routeFile.replace('.routes.js', ''); // e.g., 'auth', 'user', 'upload'
72
+
73
+ // Pluralize route names for better REST convention
74
+ if (routeName === 'user') routeName = 'users';
75
+ if (routeName === 'citation') routeName = 'citations';
76
+ if (routeName === 'upload') routeName = 'uploads';
77
+
78
+ const routePath = path.join(routesPath, routeFile);
79
+
80
+ try {
81
+ const routeModule = await import(`file://${routePath}`);
82
+ const router = routeModule.default;
83
+
84
+ // Register route: /api/v1/auth, /api/v1/users, /api/v1/uploads, etc.
85
+ const apiPath = `/api/${version}/${routeName}`;
86
+ app.use(apiPath, router);
87
+ console.log(`✓ Route registered: ${apiPath}`);
88
+ } catch (err) {
89
+ console.error(`✗ Failed to load route ${routePath}:`, err.message);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ // Error handler — normalize multer and validation errors
97
+ app.use((err, req, res, next) => {
98
+ // Multer-specific errors
99
+ if (err?.name === 'MulterError') {
100
+ const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
101
+ req.log?.warn({ err }, 'Multer error');
102
+ return res.status(status).json({ error: err.message, code: err.code });
103
+ }
104
+
105
+ // Errors thrown by validators / custom errors
106
+ if (err && (err.message || err.status)) {
107
+ const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 400;
108
+ req.log?.warn({ err }, 'Request error');
109
+ return res.status(status).json({ error: err.message || 'Bad Request' });
110
+ }
111
+
112
+ // Fallback to 500
113
+ if (err) {
114
+ req.log?.error({ err }, 'Unhandled error');
115
+ return res.status(500).json({ error: 'Internal Server Error' });
116
+ }
117
+
118
+ return next();
119
+ });
120
+
121
+ export default app;
@@ -0,0 +1,30 @@
1
+ import swaggerJsdoc from 'swagger-jsdoc';
2
+
3
+ const options = {
4
+ definition: {
5
+ openapi: '3.0.0',
6
+ info: {
7
+ title: 'API documentation',
8
+ version: process.env.npm_package_version || '1.0.0',
9
+ description: 'MVC API documentation blank template'
10
+ },
11
+ servers: [
12
+ { url: '/api/v1' },
13
+ { url: '/api/v2' }
14
+ ],
15
+ components: {
16
+ securitySchemes: {
17
+ bearerAuth: {
18
+ type: 'http',
19
+ scheme: 'bearer',
20
+ bearerFormat: 'JWT'
21
+ }
22
+ }
23
+ }
24
+ },
25
+ apis: ['./src/v*/routes/*.js', './src/v*/controllers/*.js']
26
+ };
27
+
28
+ const swaggerSpec = swaggerJsdoc(options);
29
+
30
+ export default swaggerSpec;
@@ -0,0 +1,10 @@
1
+ module.exports = {
2
+ testEnvironment: 'node',
3
+ extensionsToTreatAsEsm: ['.js'],
4
+ transform: {},
5
+ testMatch: ['**/__tests__/**/*.test.js'],
6
+ moduleNameMapper: {
7
+ '^(\\.{1,2}/.*)\\.js$': '$1'
8
+ },
9
+ transformIgnorePatterns: ['node_modules/(?!(supertest|.*\\.mjs$))']
10
+ };
package/src/server.js ADDED
@@ -0,0 +1,10 @@
1
+ import app, { initializeRoutes } from './app.js';
2
+
3
+ const PORT = process.env.PORT || 3000;
4
+
5
+ // Initialize routes then start server
6
+ await initializeRoutes();
7
+
8
+ app.listen(PORT, () => {
9
+ console.log(`🚀 Server running on port ${PORT}`);
10
+ });
@@ -0,0 +1,7 @@
1
+ import jwt from 'jsonwebtoken';
2
+
3
+ export const generateToken = (payload) => {
4
+ return jwt.sign(payload, process.env.JWT_SECRET, {
5
+ expiresIn: process.env.JWT_EXPIRE
6
+ });
7
+ };
@@ -0,0 +1,26 @@
1
+ import { PrismaMariaDb } from '@prisma/adapter-mariadb';
2
+ import { PrismaClient } from '../../generated/prisma/client.js';
3
+
4
+ let prisma;
5
+
6
+ export function initializePrisma() {
7
+ const maria = new PrismaMariaDb({
8
+ host: process.env.DATABASE_HOST,
9
+ port: process.env.DATABASE_PORT,
10
+ user: process.env.DATABASE_USER,
11
+ password: process.env.DATABASE_PASSWORD,
12
+ database: process.env.DATABASE_NAME
13
+ });
14
+
15
+ prisma = new PrismaClient({ adapter: maria });
16
+ return prisma;
17
+ }
18
+
19
+ export function getPrisma() {
20
+ if (!prisma) {
21
+ throw new Error('Prisma has not been initialized. Call initializePrisma() first.');
22
+ }
23
+ return prisma;
24
+ }
25
+
26
+ export default prisma;
@@ -0,0 +1,21 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import pino from 'pino';
4
+ import pinoHttp from 'pino-http';
5
+
6
+ const LOG_DIR = process.env.LOG_DIR || 'logs';
7
+ const LOG_FILE = process.env.LOG_FILE || 'app.log';
8
+
9
+ // Ensure log directory exists
10
+ if (!fs.existsSync(LOG_DIR)) {
11
+ fs.mkdirSync(LOG_DIR, { recursive: true });
12
+ }
13
+
14
+ const logPath = path.join(LOG_DIR, LOG_FILE);
15
+ const destination = pino.destination(logPath);
16
+
17
+ const logger = pino({ level: process.env.LOG_LEVEL || 'info' }, destination);
18
+
19
+ const pinoMiddleware = pinoHttp({ logger });
20
+
21
+ export { logger, pinoMiddleware };
@@ -0,0 +1,19 @@
1
+ import bcrypt from 'bcryptjs';
2
+ import { generateToken } from '../services/jwt.service.js';
3
+
4
+ // this is an example controller for handling authentication (register, login)
5
+ export const register = async (req, res) => {
6
+ // TODO : enregistrer l'utilisateur
7
+ res.json({ message: 'Register OK' });
8
+ };
9
+
10
+ export const login = async (req, res) => {
11
+ // TODO : vérifier email + mot de passe
12
+
13
+ const token = generateToken({
14
+ id: 1,
15
+ role: 'user'
16
+ });
17
+
18
+ res.json({ token });
19
+ };
@@ -0,0 +1,15 @@
1
+ // this is an exemple controller for handling file uploads
2
+ export const uploadFile = (req, res) => {
3
+
4
+ if (!req.file) {
5
+ req.log?.warn("No file uploaded");
6
+ return res.status(400).json({ error: 'No file uploaded' });
7
+ }
8
+
9
+ // Log upload
10
+ req.log?.info({ filename: req.file.filename, size: req.file.size }, 'file uploaded');
11
+
12
+ const uploadDir = process.env.UPLOAD_DIR || 'uploads';
13
+ const fileUrl = `${req.protocol}://${req.get('host')}/${uploadDir}/${req.file.filename}`;
14
+ res.status(201).json({ filename: req.file.filename, url: fileUrl });
15
+ };
@@ -0,0 +1,16 @@
1
+ // this is an example controller for handling user profile and info endpoints
2
+ export const profile = (req, res) => {
3
+ req.log?.info({ userId: req.user?.id }, 'profile requested');
4
+ res.json({
5
+ id: req.user.id,
6
+ role: req.user.role
7
+ });
8
+ };
9
+
10
+ export const info = (req, res) => {
11
+ req.log?.info('info endpoint');
12
+ res.status(200);
13
+ res.json({
14
+ msg: "ok"
15
+ });
16
+ };
@@ -0,0 +1,19 @@
1
+ import jwt from 'jsonwebtoken';
2
+
3
+ export const authMiddleware = (req, res, next) => {
4
+ const authHeader = req.headers.authorization;
5
+
6
+ if (!authHeader) {
7
+ return res.status(401).json({ error: 'Token missing' });
8
+ }
9
+
10
+ const token = authHeader.split(' ')[1];
11
+
12
+ try {
13
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
14
+ req.user = decoded;
15
+ next();
16
+ } catch {
17
+ res.status(401).json({ error: 'Invalid token' });
18
+ }
19
+ };
@@ -0,0 +1,8 @@
1
+ export const roleMiddleware = (role) => {
2
+ return (req, res, next) => {
3
+ if (req.user.role !== role) {
4
+ return res.status(403).json({ error: 'Forbidden' });
5
+ }
6
+ next();
7
+ };
8
+ };
@@ -0,0 +1,6 @@
1
+ export default {
2
+ id: Number,
3
+ email: String,
4
+ password: String,
5
+ role: String
6
+ };
@@ -0,0 +1,9 @@
1
+ import { Router } from 'express';
2
+ import { register, login } from '../controllers/auth.controller.js';
3
+
4
+ const router = Router();
5
+
6
+ router.post('/register', register);
7
+ router.post('/login', login);
8
+
9
+ export default router;
@@ -0,0 +1,80 @@
1
+ import { Router } from 'express';
2
+ import multer from 'multer';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { uploadFile } from '../controllers/upload.controller.js';
6
+
7
+ const router = Router();
8
+
9
+ const uploadDir = process.env.UPLOAD_DIR || 'uploads';
10
+ const MAX_SIZE = parseInt(process.env.UPLOAD_MAX_SIZE || String(5 * 1024 * 1024), 10); // default 5MB
11
+
12
+ // Ensure upload directory exists
13
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
14
+
15
+ const storage = multer.diskStorage({
16
+ destination: (req, file, cb) => cb(null, uploadDir),
17
+ filename: (req, file, cb) => {
18
+ const ext = path.extname(file.originalname);
19
+ const name = `${Date.now()}-${Math.random().toString(36).slice(2,8)}${ext}`;
20
+ cb(null, name);
21
+ }
22
+ });
23
+
24
+ const imageFileFilter = (req, file, cb) => {
25
+ const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
26
+ if (allowed.includes(file.mimetype)) cb(null, true);
27
+ else cb(new Error('Only image files are allowed'), false);
28
+ };
29
+
30
+ const upload = multer({ storage, fileFilter: imageFileFilter, limits: { fileSize: MAX_SIZE } });
31
+
32
+ /**
33
+ * @openapi
34
+ * /uploads:
35
+ * post:
36
+ * summary: Upload an image file
37
+ * consumes:
38
+ * - multipart/form-data
39
+ * requestBody:
40
+ * content:
41
+ * multipart/form-data:
42
+ * schema:
43
+ * type: object
44
+ * properties:
45
+ * file:
46
+ * type: string
47
+ * format: binary
48
+ * responses:
49
+ * 201:
50
+ * description: File uploaded
51
+ */
52
+
53
+ const storageWithName = multer.diskStorage({
54
+ destination: (req, file, cb) => cb(null, uploadDir),
55
+ filename: (req, file, cb) => {
56
+ const requested = req.params?.name || '';
57
+ // sanitize base name and keep or infer extension
58
+ const safeBase = path.basename(requested, path.extname(requested)).replace(/[^a-zA-Z0-9-_\.]/g, '-');
59
+ const extFromReq = path.extname(requested);
60
+ const ext = extFromReq || path.extname(file.originalname) || '';
61
+ const name = safeBase ? `${safeBase}${ext}` : `${Date.now()}-${Math.random().toString(36).slice(2,8)}${ext}`;
62
+ cb(null, name);
63
+ }
64
+ });
65
+
66
+ const uploadWithName = multer({ storage: storageWithName, fileFilter: imageFileFilter, limits: { fileSize: MAX_SIZE } });
67
+
68
+ router.post('/:name', (req, res, next) => {
69
+ const handler = uploadWithName.fields([{ name: 'file', maxCount: 1 }, { name: 'image', maxCount: 1 }]);
70
+ handler(req, res, (err) => {
71
+ if (err) return next(err);
72
+ if (!req.file && req.files) {
73
+ if (req.files.file && req.files.file.length) req.file = req.files.file[0];
74
+ else if (req.files.image && req.files.image.length) req.file = req.files.image[0];
75
+ }
76
+ uploadFile(req, res, next);
77
+ });
78
+ });
79
+
80
+ export default router;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @openapi
3
+ * /users/profile:
4
+ * get:
5
+ * summary: Get current user profile
6
+ * security:
7
+ * - bearerAuth: []
8
+ * responses:
9
+ * 200:
10
+ * description: User profile
11
+ * content:
12
+ * application/json:
13
+ * schema:
14
+ * type: object
15
+ * properties:
16
+ * id:
17
+ * type: string
18
+ * role:
19
+ * type: string
20
+ * 401:
21
+ * description: Unauthorized
22
+ */
23
+ import { Router } from 'express';
24
+ import { profile, info } from '../controllers/user.controller.js';
25
+ import { authMiddleware } from '../middlewares/auth.middleware.js';
26
+
27
+ const router = Router();
28
+
29
+ router.get('/profile', authMiddleware, profile);
30
+
31
+ /**
32
+ * @openapi
33
+ * /users/info:
34
+ * get:
35
+ * summary: Simple info endpoint
36
+ * responses:
37
+ * 200:
38
+ * description: OK
39
+ * content:
40
+ * application/json:
41
+ * schema:
42
+ * type: object
43
+ * properties:
44
+ * msg:
45
+ * type: string
46
+ */
47
+ router.get('/info', info);
48
+
49
+ export default router;
@@ -0,0 +1,7 @@
1
+ import jwt from 'jsonwebtoken';
2
+
3
+ export const generateToken = (payload) => {
4
+ return jwt.sign(payload, process.env.JWT_SECRET, {
5
+ expiresIn: process.env.JWT_EXPIRE
6
+ });
7
+ };