@ferjssilva/fast-crud-api
Version:
A complete and fast crud API generator
246 lines (210 loc) • 7.6 kB
JavaScript
const { transformDocument } = require('../utils/document');
const { buildQuery } = require('../utils/query');
const { isMethodAllowed } = require('../validators/method');
const { createUserScopeHandler } = require('../middleware/user-scope');
/**
* Setup basic CRUD routes for a model
* @param {Object} fastify - Fastify instance
* @param {Object} model - Mongoose model
* @param {String} baseRoute - Base route path
* @param {Object} options - Route options
* @param {Object} options.methods - Allowed methods per model
* @param {Array} options.userScoped - Array of user-scoped resource names
*/
function setupCrudRoutes(fastify, model, baseRoute, options = {}) {
const { methods = {}, userScoped = [] } = options;
const modelName = model.collection.name;
// Create user scope handler if applicable
const userScopeHandler = createUserScopeHandler(model, modelName, userScoped);
// Get searchable fields from schema
const searchableFields = Object.keys(model.schema.paths).filter(
path => model.schema.paths[path].instance === 'String'
);
// Get reference fields
const referenceFields = Object.keys(model.schema.paths).filter(path => {
const schemaType = model.schema.paths[path];
return schemaType.options && schemaType.options.ref;
});
// List route (GET /api/resource)
if (isMethodAllowed(modelName, 'GET', methods)) {
const routeOptions = {
preHandler: userScopeHandler || undefined,
handler: async (request) => {
const {
page = 1,
limit = 10,
sort,
search,
populate,
...filters
} = request.query;
// Convert string values to ObjectId for reference fields
referenceFields.forEach(field => {
if (filters[field]) {
filters[field] = model.schema.paths[field].cast(filters[field]);
}
});
const sortQuery = sort ? JSON.parse(sort) : { _id: -1 };
const query = buildQuery(model, filters, {
page: parseInt(page),
limit: parseInt(limit),
sort: sortQuery,
search,
searchFields: searchableFields,
populate
});
const [data, total] = await Promise.all([
query.exec(),
model.countDocuments(filters)
]);
return {
data: data.map(doc => transformDocument(doc)),
pagination: {
total,
page: parseInt(page),
limit: parseInt(limit),
pages: Math.ceil(total / limit)
}
};
}
};
fastify.get(baseRoute, routeOptions);
// Get single resource (GET /api/resource/:id)
const getSingleRouteOptions = {
preHandler: userScopeHandler || undefined,
handler: async (request, reply) => {
const { id } = request.params;
const { populate } = request.query;
let query;
// For user-scoped resources, MUST have userId (fail closed)
if (userScopeHandler) {
if (!request.userId) {
// Defense in depth: preHandler should catch this, but fail closed
reply.code(401).send({
error: 'Unauthorized',
message: 'Authentication required'
});
return;
}
// Atomic query with userId filter
query = model.findOne({ _id: id, userId: request.userId });
} else {
// Non-user-scoped resources use standard findById
query = model.findById(id);
}
if (populate) {
const populateFields = Array.isArray(populate) ? populate : [populate];
populateFields.forEach(field => {
query = query.populate(field);
});
}
const doc = await query.exec();
if (!doc) {
reply.code(404).send({
error: 'NotFound',
message: 'Resource not found'
});
return;
}
return transformDocument(doc);
}
};
fastify.get(`${baseRoute}/:id`, getSingleRouteOptions);
}
// Create resource (POST /api/resource)
if (isMethodAllowed(modelName, 'POST', methods)) {
const postRouteOptions = {
preHandler: userScopeHandler || undefined,
handler: async (request) => {
const doc = new model(request.body);
await doc.save();
return transformDocument(doc);
}
};
fastify.post(baseRoute, postRouteOptions);
}
// Update resource (PUT /api/resource/:id)
if (isMethodAllowed(modelName, 'PUT', methods)) {
const putRouteOptions = {
preHandler: userScopeHandler || undefined,
handler: async (request, reply) => {
const { id } = request.params;
let doc;
// For user-scoped resources, MUST have userId (fail closed)
if (userScopeHandler) {
if (!request.userId) {
// Defense in depth: preHandler should catch this, but fail closed
reply.code(401).send({
error: 'Unauthorized',
message: 'Authentication required'
});
return;
}
// Atomic update: only updates if BOTH id AND userId match
doc = await model.findOneAndUpdate(
{ _id: id, userId: request.userId },
request.body,
{ new: true, runValidators: true }
);
} else {
// Non-user-scoped resources use standard findByIdAndUpdate
doc = await model.findByIdAndUpdate(
id,
request.body,
{ new: true, runValidators: true }
);
}
if (!doc) {
// Could be 404 (not found) or 403 (found but wrong userId for user-scoped)
// For security, we return 404 to not leak existence of resources
reply.code(404).send({
error: 'NotFound',
message: 'Resource not found'
});
return;
}
return transformDocument(doc);
}
};
fastify.put(`${baseRoute}/:id`, putRouteOptions);
}
// Delete resource (DELETE /api/resource/:id)
if (isMethodAllowed(modelName, 'DELETE', methods)) {
const deleteRouteOptions = {
preHandler: userScopeHandler || undefined,
handler: async (request, reply) => {
const { id } = request.params;
let doc;
// For user-scoped resources, MUST have userId (fail closed)
if (userScopeHandler) {
if (!request.userId) {
// Defense in depth: preHandler should catch this, but fail closed
reply.code(401).send({
error: 'Unauthorized',
message: 'Authentication required'
});
return;
}
// Atomic delete: only deletes if BOTH id AND userId match
doc = await model.findOneAndDelete({ _id: id, userId: request.userId });
} else {
// Non-user-scoped resources use standard findByIdAndDelete
doc = await model.findByIdAndDelete(id);
}
if (!doc) {
// Could be 404 (not found) or 403 (found but wrong userId for user-scoped)
// For security, we return 404 to not leak existence of resources
reply.code(404).send({
error: 'NotFound',
message: 'Resource not found'
});
return;
}
return { success: true };
}
};
fastify.delete(`${baseRoute}/:id`, deleteRouteOptions);
}
return { referenceFields }; // Return for use in nested routes
}
module.exports = { setupCrudRoutes };