create-listablelabs-api 1.0.1
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.
- package/README.md +142 -0
- package/bin/cli.js +37 -0
- package/bin/commands/add.js +460 -0
- package/bin/commands/create.js +481 -0
- package/package.json +39 -0
- package/templates/base/.dockerignore +19 -0
- package/templates/base/.env.example +18 -0
- package/templates/base/.eslintrc.js +31 -0
- package/templates/base/Dockerfile +48 -0
- package/templates/base/README.md +295 -0
- package/templates/base/docker-compose.yml +55 -0
- package/templates/base/jest.config.js +24 -0
- package/templates/base/package.json +41 -0
- package/templates/base/src/app.js +103 -0
- package/templates/base/src/config/index.js +36 -0
- package/templates/base/src/controllers/exampleController.js +148 -0
- package/templates/base/src/database/baseModel.js +160 -0
- package/templates/base/src/database/index.js +108 -0
- package/templates/base/src/middlewares/errorHandler.js +155 -0
- package/templates/base/src/middlewares/index.js +49 -0
- package/templates/base/src/middlewares/rateLimiter.js +85 -0
- package/templates/base/src/middlewares/requestLogger.js +50 -0
- package/templates/base/src/middlewares/validator.js +107 -0
- package/templates/base/src/models/example.js +117 -0
- package/templates/base/src/models/index.js +6 -0
- package/templates/base/src/routes/v1/exampleRoutes.js +89 -0
- package/templates/base/src/routes/v1/index.js +19 -0
- package/templates/base/src/server.js +80 -0
- package/templates/base/src/utils/logger.js +61 -0
- package/templates/base/src/utils/response.js +117 -0
- package/templates/base/tests/app.test.js +215 -0
- package/templates/base/tests/setup.js +33 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base schema options applied to all models
|
|
5
|
+
*/
|
|
6
|
+
const baseSchemaOptions = {
|
|
7
|
+
timestamps: true,
|
|
8
|
+
toJSON: {
|
|
9
|
+
virtuals: true,
|
|
10
|
+
transform: (doc, ret) => {
|
|
11
|
+
ret.id = ret._id.toString();
|
|
12
|
+
delete ret._id;
|
|
13
|
+
delete ret.__v;
|
|
14
|
+
return ret;
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
toObject: {
|
|
18
|
+
virtuals: true,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Common plugins for all schemas
|
|
24
|
+
*/
|
|
25
|
+
const paginatePlugin = (schema) => {
|
|
26
|
+
/**
|
|
27
|
+
* Paginate query results
|
|
28
|
+
* @param {Object} filter - Mongoose filter query
|
|
29
|
+
* @param {Object} options - Pagination options
|
|
30
|
+
* @param {number} options.page - Current page (default: 1)
|
|
31
|
+
* @param {number} options.limit - Results per page (default: 20)
|
|
32
|
+
* @param {string} options.sortBy - Sort field (default: createdAt)
|
|
33
|
+
* @param {string} options.sortOrder - Sort order 'asc' or 'desc' (default: desc)
|
|
34
|
+
* @param {string} options.populate - Fields to populate
|
|
35
|
+
*/
|
|
36
|
+
schema.statics.paginate = async function (filter = {}, options = {}) {
|
|
37
|
+
const {
|
|
38
|
+
page = 1,
|
|
39
|
+
limit = 20,
|
|
40
|
+
sortBy = 'createdAt',
|
|
41
|
+
sortOrder = 'desc',
|
|
42
|
+
populate = '',
|
|
43
|
+
} = options;
|
|
44
|
+
|
|
45
|
+
const skip = (page - 1) * limit;
|
|
46
|
+
const sort = { [sortBy]: sortOrder === 'asc' ? 1 : -1 };
|
|
47
|
+
|
|
48
|
+
const [data, total] = await Promise.all([
|
|
49
|
+
this.find(filter)
|
|
50
|
+
.sort(sort)
|
|
51
|
+
.skip(skip)
|
|
52
|
+
.limit(limit)
|
|
53
|
+
.populate(populate)
|
|
54
|
+
.lean(),
|
|
55
|
+
this.countDocuments(filter),
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
data,
|
|
60
|
+
pagination: {
|
|
61
|
+
page,
|
|
62
|
+
limit,
|
|
63
|
+
total,
|
|
64
|
+
totalPages: Math.ceil(total / limit),
|
|
65
|
+
hasNextPage: page < Math.ceil(total / limit),
|
|
66
|
+
hasPrevPage: page > 1,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Soft delete plugin
|
|
74
|
+
*/
|
|
75
|
+
const softDeletePlugin = (schema) => {
|
|
76
|
+
schema.add({
|
|
77
|
+
isDeleted: { type: Boolean, default: false },
|
|
78
|
+
deletedAt: { type: Date, default: null },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Override find methods to exclude soft-deleted docs by default
|
|
82
|
+
schema.pre(/^find/, function (next) {
|
|
83
|
+
if (this.getQuery().includeDeleted !== true) {
|
|
84
|
+
this.where({ isDeleted: false });
|
|
85
|
+
}
|
|
86
|
+
next();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
schema.methods.softDelete = async function () {
|
|
90
|
+
this.isDeleted = true;
|
|
91
|
+
this.deletedAt = new Date();
|
|
92
|
+
return this.save();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
schema.methods.restore = async function () {
|
|
96
|
+
this.isDeleted = false;
|
|
97
|
+
this.deletedAt = null;
|
|
98
|
+
return this.save();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
schema.statics.findDeleted = function () {
|
|
102
|
+
return this.find({ isDeleted: true, includeDeleted: true });
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a model with common plugins
|
|
108
|
+
* @param {string} name - Model name
|
|
109
|
+
* @param {Object} schemaDefinition - Schema fields
|
|
110
|
+
* @param {Object} options - Additional options
|
|
111
|
+
*/
|
|
112
|
+
const createModel = (name, schemaDefinition, options = {}) => {
|
|
113
|
+
const schema = new mongoose.Schema(schemaDefinition, {
|
|
114
|
+
...baseSchemaOptions,
|
|
115
|
+
...options.schemaOptions,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Apply plugins
|
|
119
|
+
schema.plugin(paginatePlugin);
|
|
120
|
+
|
|
121
|
+
if (options.softDelete) {
|
|
122
|
+
schema.plugin(softDeletePlugin);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Add indexes if provided
|
|
126
|
+
if (options.indexes) {
|
|
127
|
+
options.indexes.forEach((index) => {
|
|
128
|
+
schema.index(index.fields, index.options);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Add methods if provided
|
|
133
|
+
if (options.methods) {
|
|
134
|
+
Object.assign(schema.methods, options.methods);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Add statics if provided
|
|
138
|
+
if (options.statics) {
|
|
139
|
+
Object.assign(schema.statics, options.statics);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Add virtuals if provided
|
|
143
|
+
if (options.virtuals) {
|
|
144
|
+
Object.entries(options.virtuals).forEach(([name, config]) => {
|
|
145
|
+
const virtual = schema.virtual(name);
|
|
146
|
+
if (config.get) virtual.get(config.get);
|
|
147
|
+
if (config.set) virtual.set(config.set);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return mongoose.model(name, schema);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
baseSchemaOptions,
|
|
156
|
+
paginatePlugin,
|
|
157
|
+
softDeletePlugin,
|
|
158
|
+
createModel,
|
|
159
|
+
mongoose,
|
|
160
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
const { logger } = require('../utils/logger');
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MongoDB Atlas Connection
|
|
7
|
+
*
|
|
8
|
+
* Connection string format:
|
|
9
|
+
* mongodb+srv://<username>:<password>@<cluster>.mongodb.net/<database>?retryWrites=true&w=majority
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const connectDB = async () => {
|
|
13
|
+
try {
|
|
14
|
+
const conn = await mongoose.connect(config.mongoUri, {
|
|
15
|
+
// Connection pool settings
|
|
16
|
+
maxPoolSize: 10,
|
|
17
|
+
minPoolSize: 2,
|
|
18
|
+
|
|
19
|
+
// Timeouts
|
|
20
|
+
serverSelectionTimeoutMS: 5000,
|
|
21
|
+
socketTimeoutMS: 45000,
|
|
22
|
+
|
|
23
|
+
// Buffering
|
|
24
|
+
bufferCommands: false,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
logger.info({
|
|
28
|
+
host: conn.connection.host,
|
|
29
|
+
name: conn.connection.name,
|
|
30
|
+
}, '✅ MongoDB Atlas connected');
|
|
31
|
+
|
|
32
|
+
return conn;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
logger.fatal({ err }, '❌ MongoDB connection failed');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Connection event handlers
|
|
41
|
+
*/
|
|
42
|
+
mongoose.connection.on('connected', () => {
|
|
43
|
+
logger.debug('Mongoose connected to MongoDB');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
mongoose.connection.on('disconnected', () => {
|
|
47
|
+
logger.warn('Mongoose disconnected from MongoDB');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
mongoose.connection.on('error', (err) => {
|
|
51
|
+
logger.error({ err }, 'Mongoose connection error');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Graceful shutdown
|
|
56
|
+
*/
|
|
57
|
+
const disconnectDB = async () => {
|
|
58
|
+
try {
|
|
59
|
+
await mongoose.connection.close();
|
|
60
|
+
logger.info('MongoDB connection closed');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.error({ err }, 'Error closing MongoDB connection');
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Health check
|
|
68
|
+
*/
|
|
69
|
+
const healthCheck = async () => {
|
|
70
|
+
return mongoose.connection.readyState === 1;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get connection status for health endpoints
|
|
75
|
+
*/
|
|
76
|
+
const getConnectionStatus = () => {
|
|
77
|
+
const state = mongoose.connection.readyState;
|
|
78
|
+
const states = {
|
|
79
|
+
0: 'disconnected',
|
|
80
|
+
1: 'connected',
|
|
81
|
+
2: 'connecting',
|
|
82
|
+
3: 'disconnecting',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return states[state] || 'unknown';
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get connection stats
|
|
90
|
+
*/
|
|
91
|
+
const getStats = () => {
|
|
92
|
+
const { connection } = mongoose;
|
|
93
|
+
return {
|
|
94
|
+
readyState: connection.readyState,
|
|
95
|
+
host: connection.host,
|
|
96
|
+
name: connection.name,
|
|
97
|
+
collections: Object.keys(connection.collections),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
connectDB,
|
|
103
|
+
disconnectDB,
|
|
104
|
+
healthCheck,
|
|
105
|
+
getConnectionStatus,
|
|
106
|
+
getStats,
|
|
107
|
+
mongoose,
|
|
108
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const { StatusCodes, ReasonPhrases } = require('http-status-codes');
|
|
2
|
+
const { logger } = require('../utils/logger');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom Application Error class
|
|
6
|
+
* Use this for all operational errors (expected errors)
|
|
7
|
+
*/
|
|
8
|
+
class AppError extends Error {
|
|
9
|
+
constructor(message, statusCode, errorCode = null) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.statusCode = statusCode;
|
|
12
|
+
this.errorCode = errorCode || this.constructor.name;
|
|
13
|
+
this.isOperational = true; // Distinguishes operational errors from programming errors
|
|
14
|
+
this.timestamp = new Date().toISOString();
|
|
15
|
+
|
|
16
|
+
Error.captureStackTrace(this, this.constructor);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Common error types for consistent error handling
|
|
22
|
+
*/
|
|
23
|
+
class BadRequestError extends AppError {
|
|
24
|
+
constructor(message = 'Bad Request') {
|
|
25
|
+
super(message, StatusCodes.BAD_REQUEST, 'BAD_REQUEST');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class UnauthorizedError extends AppError {
|
|
30
|
+
constructor(message = 'Unauthorized') {
|
|
31
|
+
super(message, StatusCodes.UNAUTHORIZED, 'UNAUTHORIZED');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ForbiddenError extends AppError {
|
|
36
|
+
constructor(message = 'Forbidden') {
|
|
37
|
+
super(message, StatusCodes.FORBIDDEN, 'FORBIDDEN');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class NotFoundError extends AppError {
|
|
42
|
+
constructor(message = 'Resource not found') {
|
|
43
|
+
super(message, StatusCodes.NOT_FOUND, 'NOT_FOUND');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class ConflictError extends AppError {
|
|
48
|
+
constructor(message = 'Resource conflict') {
|
|
49
|
+
super(message, StatusCodes.CONFLICT, 'CONFLICT');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class ValidationError extends AppError {
|
|
54
|
+
constructor(message = 'Validation failed', details = []) {
|
|
55
|
+
super(message, StatusCodes.UNPROCESSABLE_ENTITY, 'VALIDATION_ERROR');
|
|
56
|
+
this.details = details;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class TooManyRequestsError extends AppError {
|
|
61
|
+
constructor(message = 'Too many requests') {
|
|
62
|
+
super(message, StatusCodes.TOO_MANY_REQUESTS, 'RATE_LIMIT_EXCEEDED');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class InternalServerError extends AppError {
|
|
67
|
+
constructor(message = 'Internal server error') {
|
|
68
|
+
super(message, StatusCodes.INTERNAL_SERVER_ERROR, 'INTERNAL_ERROR');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Centralized error handler middleware
|
|
74
|
+
* Must be registered LAST in middleware chain
|
|
75
|
+
*/
|
|
76
|
+
const errorHandler = (err, req, res, next) => {
|
|
77
|
+
// Default values for unknown errors
|
|
78
|
+
let statusCode = err.statusCode || StatusCodes.INTERNAL_SERVER_ERROR;
|
|
79
|
+
let message = err.message || ReasonPhrases.INTERNAL_SERVER_ERROR;
|
|
80
|
+
let errorCode = err.errorCode || 'INTERNAL_ERROR';
|
|
81
|
+
|
|
82
|
+
// Log the error
|
|
83
|
+
const errorLog = {
|
|
84
|
+
requestId: req.id,
|
|
85
|
+
method: req.method,
|
|
86
|
+
path: req.originalUrl,
|
|
87
|
+
statusCode,
|
|
88
|
+
errorCode,
|
|
89
|
+
message: err.message,
|
|
90
|
+
stack: err.stack,
|
|
91
|
+
isOperational: err.isOperational || false,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Log level based on error type
|
|
95
|
+
if (err.isOperational) {
|
|
96
|
+
logger.warn(errorLog, 'Operational error occurred');
|
|
97
|
+
} else {
|
|
98
|
+
logger.error(errorLog, 'Unexpected error occurred');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Don't leak internal details in production
|
|
102
|
+
if (process.env.NODE_ENV === 'production' && !err.isOperational) {
|
|
103
|
+
message = ReasonPhrases.INTERNAL_SERVER_ERROR;
|
|
104
|
+
errorCode = 'INTERNAL_ERROR';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Build error response
|
|
108
|
+
const errorResponse = {
|
|
109
|
+
success: false,
|
|
110
|
+
error: {
|
|
111
|
+
code: errorCode,
|
|
112
|
+
message,
|
|
113
|
+
...(err.details && { details: err.details }),
|
|
114
|
+
},
|
|
115
|
+
requestId: req.id,
|
|
116
|
+
timestamp: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Include stack trace in development
|
|
120
|
+
if (process.env.NODE_ENV === 'development') {
|
|
121
|
+
errorResponse.error.stack = err.stack;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
res.status(statusCode).json(errorResponse);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Async handler wrapper to catch errors in async route handlers
|
|
129
|
+
* Usage: router.get('/path', asyncHandler(async (req, res) => { ... }))
|
|
130
|
+
*/
|
|
131
|
+
const asyncHandler = (fn) => (req, res, next) => {
|
|
132
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 404 Not Found handler for undefined routes
|
|
137
|
+
*/
|
|
138
|
+
const notFoundHandler = (req, res, next) => {
|
|
139
|
+
next(new NotFoundError(`Route ${req.method} ${req.originalUrl} not found`));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
AppError,
|
|
144
|
+
BadRequestError,
|
|
145
|
+
UnauthorizedError,
|
|
146
|
+
ForbiddenError,
|
|
147
|
+
NotFoundError,
|
|
148
|
+
ConflictError,
|
|
149
|
+
ValidationError,
|
|
150
|
+
TooManyRequestsError,
|
|
151
|
+
InternalServerError,
|
|
152
|
+
errorHandler,
|
|
153
|
+
asyncHandler,
|
|
154
|
+
notFoundHandler,
|
|
155
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const requestLogger = require('./requestLogger');
|
|
2
|
+
const {
|
|
3
|
+
AppError,
|
|
4
|
+
BadRequestError,
|
|
5
|
+
UnauthorizedError,
|
|
6
|
+
ForbiddenError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
TooManyRequestsError,
|
|
11
|
+
InternalServerError,
|
|
12
|
+
errorHandler,
|
|
13
|
+
asyncHandler,
|
|
14
|
+
notFoundHandler,
|
|
15
|
+
} = require('./errorHandler');
|
|
16
|
+
const { validate, schemas, z, makePartial, pick, omit } = require('./validator');
|
|
17
|
+
const { defaultLimiter, strictLimiter, createLimiter } = require('./rateLimiter');
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
// Logging
|
|
21
|
+
requestLogger,
|
|
22
|
+
|
|
23
|
+
// Error handling
|
|
24
|
+
AppError,
|
|
25
|
+
BadRequestError,
|
|
26
|
+
UnauthorizedError,
|
|
27
|
+
ForbiddenError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
ConflictError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
TooManyRequestsError,
|
|
32
|
+
InternalServerError,
|
|
33
|
+
errorHandler,
|
|
34
|
+
asyncHandler,
|
|
35
|
+
notFoundHandler,
|
|
36
|
+
|
|
37
|
+
// Validation (Zod)
|
|
38
|
+
validate,
|
|
39
|
+
schemas,
|
|
40
|
+
z,
|
|
41
|
+
makePartial,
|
|
42
|
+
pick,
|
|
43
|
+
omit,
|
|
44
|
+
|
|
45
|
+
// Rate limiting
|
|
46
|
+
defaultLimiter,
|
|
47
|
+
strictLimiter,
|
|
48
|
+
createLimiter,
|
|
49
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const rateLimit = require('express-rate-limit');
|
|
2
|
+
const config = require('../config');
|
|
3
|
+
const { TooManyRequestsError } = require('./errorHandler');
|
|
4
|
+
const { logger } = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default rate limiter
|
|
8
|
+
* Applies to all routes unless overridden
|
|
9
|
+
*/
|
|
10
|
+
const defaultLimiter = rateLimit({
|
|
11
|
+
windowMs: config.rateLimit.windowMs,
|
|
12
|
+
max: config.rateLimit.maxRequests,
|
|
13
|
+
standardHeaders: true, // Return rate limit info in headers
|
|
14
|
+
legacyHeaders: false, // Disable X-RateLimit headers
|
|
15
|
+
|
|
16
|
+
// Custom key generator - can be modified for user-based limiting
|
|
17
|
+
keyGenerator: (req) => {
|
|
18
|
+
return req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// Skip rate limiting for health checks
|
|
22
|
+
skip: (req) => {
|
|
23
|
+
return req.path === '/health' || req.path === '/ready';
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// Custom handler when limit is exceeded
|
|
27
|
+
handler: (req, res, next) => {
|
|
28
|
+
logger.warn({
|
|
29
|
+
requestId: req.id,
|
|
30
|
+
ip: req.ip,
|
|
31
|
+
path: req.originalUrl,
|
|
32
|
+
}, 'Rate limit exceeded');
|
|
33
|
+
|
|
34
|
+
next(new TooManyRequestsError('Too many requests, please try again later'));
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strict rate limiter for sensitive endpoints (auth, etc.)
|
|
40
|
+
*/
|
|
41
|
+
const strictLimiter = rateLimit({
|
|
42
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
43
|
+
max: 5, // 5 requests per window
|
|
44
|
+
standardHeaders: true,
|
|
45
|
+
legacyHeaders: false,
|
|
46
|
+
|
|
47
|
+
handler: (req, res, next) => {
|
|
48
|
+
logger.warn({
|
|
49
|
+
requestId: req.id,
|
|
50
|
+
ip: req.ip,
|
|
51
|
+
path: req.originalUrl,
|
|
52
|
+
}, 'Strict rate limit exceeded');
|
|
53
|
+
|
|
54
|
+
next(new TooManyRequestsError('Too many attempts, please try again later'));
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create custom rate limiter with specific options
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} options - Rate limiter options
|
|
62
|
+
* @returns {Function} Rate limiter middleware
|
|
63
|
+
*/
|
|
64
|
+
const createLimiter = (options) => {
|
|
65
|
+
return rateLimit({
|
|
66
|
+
standardHeaders: true,
|
|
67
|
+
legacyHeaders: false,
|
|
68
|
+
handler: (req, res, next) => {
|
|
69
|
+
logger.warn({
|
|
70
|
+
requestId: req.id,
|
|
71
|
+
ip: req.ip,
|
|
72
|
+
path: req.originalUrl,
|
|
73
|
+
}, 'Custom rate limit exceeded');
|
|
74
|
+
|
|
75
|
+
next(new TooManyRequestsError(options.message || 'Rate limit exceeded'));
|
|
76
|
+
},
|
|
77
|
+
...options,
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
defaultLimiter,
|
|
83
|
+
strictLimiter,
|
|
84
|
+
createLimiter,
|
|
85
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const pinoHttp = require('pino-http');
|
|
2
|
+
const { v4: uuidv4 } = require('uuid');
|
|
3
|
+
const { logger } = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
const requestLogger = pinoHttp({
|
|
6
|
+
logger,
|
|
7
|
+
|
|
8
|
+
// Generate unique request ID
|
|
9
|
+
genReqId: (req) => {
|
|
10
|
+
return req.headers['x-request-id'] || uuidv4();
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
// Custom log level based on response status
|
|
14
|
+
customLogLevel: (req, res, err) => {
|
|
15
|
+
if (res.statusCode >= 500 || err) return 'error';
|
|
16
|
+
if (res.statusCode >= 400) return 'warn';
|
|
17
|
+
return 'info';
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// Custom success message
|
|
21
|
+
customSuccessMessage: (req, res) => {
|
|
22
|
+
return `${req.method} ${req.url} completed`;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// Custom error message
|
|
26
|
+
customErrorMessage: (req, res, err) => {
|
|
27
|
+
return `${req.method} ${req.url} failed: ${err.message}`;
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// Custom attributes to add to log
|
|
31
|
+
customAttributeKeys: {
|
|
32
|
+
req: 'request',
|
|
33
|
+
res: 'response',
|
|
34
|
+
err: 'error',
|
|
35
|
+
responseTime: 'duration',
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Custom props to add to each log
|
|
39
|
+
customProps: (req, res) => ({
|
|
40
|
+
requestId: req.id,
|
|
41
|
+
userAgent: req.headers['user-agent'],
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
// Don't log health checks to reduce noise
|
|
45
|
+
autoLogging: {
|
|
46
|
+
ignore: (req) => req.url === '/health' || req.url === '/ready',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
module.exports = requestLogger;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
const { ValidationError } = require('./errorHandler');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validation middleware factory using Zod
|
|
6
|
+
* Creates middleware that validates request data against Zod schemas
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} schema - Object containing Zod schemas for body, query, params
|
|
9
|
+
* @returns {Function} Express middleware
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const schema = {
|
|
13
|
+
* body: z.object({ name: z.string().min(1) }),
|
|
14
|
+
* params: z.object({ id: z.string().uuid() }),
|
|
15
|
+
* query: z.object({ page: z.coerce.number().int().min(1).optional() }),
|
|
16
|
+
* };
|
|
17
|
+
* router.post('/users/:id', validate(schema), controller.updateUser);
|
|
18
|
+
*/
|
|
19
|
+
const validate = (schema) => {
|
|
20
|
+
return (req, res, next) => {
|
|
21
|
+
const validationErrors = [];
|
|
22
|
+
|
|
23
|
+
// Validate each part of the request
|
|
24
|
+
['body', 'query', 'params'].forEach((key) => {
|
|
25
|
+
if (schema[key]) {
|
|
26
|
+
const result = schema[key].safeParse(req[key]);
|
|
27
|
+
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
result.error.errors.forEach((err) => {
|
|
30
|
+
validationErrors.push({
|
|
31
|
+
field: err.path.join('.'),
|
|
32
|
+
message: err.message,
|
|
33
|
+
location: key,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
} else {
|
|
37
|
+
// Replace with validated/transformed values
|
|
38
|
+
req[key] = result.data;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (validationErrors.length > 0) {
|
|
44
|
+
return next(new ValidationError('Validation failed', validationErrors));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
next();
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Common Zod schema patterns for reuse
|
|
53
|
+
*/
|
|
54
|
+
const schemas = {
|
|
55
|
+
// Common field patterns
|
|
56
|
+
id: z.string().min(1),
|
|
57
|
+
mongoId: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid MongoDB ObjectId'),
|
|
58
|
+
uuid: z.string().uuid(),
|
|
59
|
+
email: z.string().email().toLowerCase().trim(),
|
|
60
|
+
password: z.string().min(8).max(128),
|
|
61
|
+
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
|
|
62
|
+
|
|
63
|
+
// Pagination
|
|
64
|
+
pagination: z.object({
|
|
65
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
66
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
67
|
+
sortBy: z.string().optional(),
|
|
68
|
+
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
// Date range
|
|
72
|
+
dateRange: z.object({
|
|
73
|
+
startDate: z.coerce.date(),
|
|
74
|
+
endDate: z.coerce.date(),
|
|
75
|
+
}).refine((data) => data.endDate > data.startDate, {
|
|
76
|
+
message: 'End date must be after start date',
|
|
77
|
+
path: ['endDate'],
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
// Common string transformations
|
|
81
|
+
trimmedString: z.string().trim(),
|
|
82
|
+
nonEmptyString: z.string().min(1).trim(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Helper to make all fields in a schema optional (for PATCH requests)
|
|
87
|
+
*/
|
|
88
|
+
const makePartial = (schema) => schema.partial();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Helper to pick specific fields from a schema
|
|
92
|
+
*/
|
|
93
|
+
const pick = (schema, keys) => schema.pick(keys.reduce((acc, key) => ({ ...acc, [key]: true }), {}));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Helper to omit specific fields from a schema
|
|
97
|
+
*/
|
|
98
|
+
const omit = (schema, keys) => schema.omit(keys.reduce((acc, key) => ({ ...acc, [key]: true }), {}));
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
validate,
|
|
102
|
+
schemas,
|
|
103
|
+
makePartial,
|
|
104
|
+
pick,
|
|
105
|
+
omit,
|
|
106
|
+
z, // Re-export Zod for convenience
|
|
107
|
+
};
|