UNPKG

@ferjssilva/fast-crud-api

Version:

A complete and fast crud API generator

246 lines (210 loc) 7.6 kB
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 };