UNPKG

@flavoai/fastfold

Version:

Zero-boilerplate backend for React apps with auto-generated CRUD and declarative security

706 lines â€ĸ 28.5 kB
// ============================================================================ // FASTFOLD - Zero-boilerplate backend for React apps // ============================================================================ // Core types export * from './types'; // Security API export { Security, SecurityEnforcer } from './security'; // Database adapters export { createDatabaseAdapter, DatabaseAdapters } from './database/adapters'; // Server export { FastfoldServer, createFastfoldServer } from './server'; // CRUD generator (for advanced usage) export { CrudGenerator } from './crud/generator'; // ============================================================================ // CONVENIENCE EXPORTS FOR COMMON USAGE // ============================================================================ import express from 'express'; import path from 'path'; import fs from 'fs'; import cors from 'cors'; import helmet from 'helmet'; import jwt from 'jsonwebtoken'; import { Security } from './security'; import { DrizzleAdapter } from './database/adapters/drizzle'; /** * Main Fastfold class for easy setup */ export class Fastfold { static Security = Security; /** * Quick start implementation */ static async quickStart(config, port = 3001) { // Detect if it's Drizzle-based or legacy format if ('drizzle' in config) { return await this.startWithDrizzle(config, port); } else { return await this.startLegacy(config, port); } } /** * Start server with Drizzle integration */ static async startWithDrizzle(config, port) { const { drizzle: drizzleConfig, tables, auth, uploads, rateLimit, endpoints, hooks, staticFrontend } = config; // Create DrizzleAdapter const adapter = new DrizzleAdapter(drizzleConfig.db, drizzleConfig.schema); // Create Express server const server = await this.createDrizzleServer({ adapter, tables, auth, uploads, rateLimit, endpoints, hooks, port, staticFrontend }); // Execute server start hook if (hooks?.onServerStart) { await hooks.onServerStart(server); } await server.start(port); return adapter; } /** * Legacy start method for backward compatibility */ static async startLegacy(config, port) { // Convert legacy config to new format for internal use const legacyTables = {}; for (const [tableName, tableDef] of Object.entries(config.tables)) { legacyTables[tableName] = { security: tableDef.security, operations: ['create', 'read', 'update', 'delete'] // All operations by default }; } // Create a minimal server for legacy support // This would use the old database adapters throw new Error('Legacy mode not yet implemented in new version. Please migrate to Drizzle.'); } /** * Create FastfoldServer with Drizzle adapter */ static async createDrizzleServer(options) { const { adapter, tables, auth, uploads, rateLimit, endpoints, hooks, port } = options; // Create Express app const app = express(); // Setup middleware app.use(cors()); app.use(helmet()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Setup authentication middleware if provided if (auth) { app.use((req, res, next) => { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7); try { const decoded = jwt.verify(token, auth.secret || 'your-secret-key'); req.user = decoded; } catch (err) { // Invalid token - continue without user } } next(); }); } // Setup CRUD routes for each table const tableNames = adapter.getTableNames().filter(name => tables[name]); for (const tableName of tableNames) { const tableConfig = tables[tableName]; const ops = tableConfig.operations || ['create', 'read', 'update', 'delete']; // GET /api/tablename - List all if (ops.includes('read')) { app.get(`/api/${tableName}`, async (req, res) => { try { // Parse query parameters const params = req.query.params ? JSON.parse(req.query.params) : req.query; // Check if 'with' parameter exists to determine which query method to use const hasRelations = params.with && Object.keys(params.with).length > 0; const results = hasRelations ? await adapter.queryWithRelations(tableName, params) : await adapter.query(tableName, params); res.json({ success: true, data: results }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // GET /api/tablename/:id - Get by ID app.get(`/api/${tableName}/:id`, async (req, res) => { try { // Parse query parameters for potential 'with' relations const params = req.query.params ? JSON.parse(req.query.params) : req.query; const queryParams = { where: { id: parseInt(req.params.id) }, limit: 1, ...params }; // Check if 'with' parameter exists const hasRelations = queryParams.with && Object.keys(queryParams.with).length > 0; const result = hasRelations ? await adapter.queryWithRelations(tableName, queryParams) : await adapter.query(tableName, queryParams); const item = result[0]; if (!item) { return res.status(404).json({ success: false, error: 'Not found' }); } res.json({ success: true, data: item }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); } // POST /api/tablename - Create if (ops.includes('create')) { app.post(`/api/${tableName}`, async (req, res) => { try { const result = await adapter.create(tableName, req.body); res.status(201).json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); } // PUT /api/tablename/:id - Update if (ops.includes('update')) { app.put(`/api/${tableName}/:id`, async (req, res) => { try { const result = await adapter.update(tableName, parseInt(req.params.id), req.body); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); } // DELETE /api/tablename/:id - Delete if (ops.includes('delete')) { app.delete(`/api/${tableName}/:id`, async (req, res) => { try { await adapter.delete(tableName, parseInt(req.params.id)); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); } } // Add basic endpoints app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString() }); }); // Add API docs endpoint app.get('/docs', (req, res) => { try { const docsHtml = this.generateDrizzleDocs(adapter, tables); res.setHeader('Content-Type', 'text/html'); res.send(docsHtml); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/docs/json', (req, res) => { try { const docsJson = this.generateDrizzleDocsJson(adapter, tables); res.json(docsJson); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Setup custom endpoints if (endpoints) { endpoints(app); } // Serve static frontend (supports multiple mounts) const staticCfg = options.staticFrontend; const mounts = !staticCfg ? [] : Array.isArray(staticCfg) ? staticCfg : [staticCfg]; for (const mount of mounts) { const directory = mount.directory || path.resolve(process.cwd(), 'dist'); const urlPath = mount.urlPath || '/'; const indexFile = mount.indexFile || 'index.html'; const staticOptions = mount.staticOptions || { index: false }; if (!fs.existsSync(directory)) { console.log(`â„šī¸ Static mount skipped: directory not found -> ${directory}`); continue; } // Mount static assets if (urlPath === '/') { app.use(express.static(directory, staticOptions)); } else { app.use(urlPath, express.static(directory, staticOptions)); } // SPA fallback if requested const spa = mount.spaFallback ?? (urlPath === '/'); if (spa) { const defaultExcludes = ['/api', '/docs']; const excludes = mount.excludePaths && mount.excludePaths.length > 0 ? mount.excludePaths : defaultExcludes; const shouldExclude = (reqPath) => { return excludes.some((p) => { if (typeof p === 'string') return reqPath.startsWith(p); return p.test(reqPath); }); }; const fallbackHandler = (req, res, next) => { if (shouldExclude(req.path)) return next(); const filePath = path.join(directory, indexFile); res.sendFile(filePath); }; if (urlPath === '/') { app.get('*', fallbackHandler); } else { app.get(`${urlPath}*`, fallbackHandler); } console.log(`đŸ—‚ī¸ Static mount: ${directory} -> ${urlPath} (SPA fallback enabled)`); } else { console.log(`đŸ—‚ī¸ Static mount: ${directory} -> ${urlPath}`); } } // Create server object const server = { adapter, tables, app, async start(serverPort) { console.log(`🚀 Fastfold server starting on port ${serverPort}`); console.log('📊 Auto-generated CRUD endpoints:'); for (const tableName of tableNames) { const tableConfig = tables[tableName]; const ops = tableConfig.operations || ['create', 'read', 'update', 'delete']; console.log(` ${tableName}:`); if (ops.includes('read')) { console.log(` GET /api/${tableName} - List ${tableName}`); console.log(` GET /api/${tableName}/:id - Get ${tableName} by ID`); } if (ops.includes('create')) { console.log(` POST /api/${tableName} - Create ${tableName}`); } if (ops.includes('update')) { console.log(` PUT /api/${tableName}/:id - Update ${tableName}`); } if (ops.includes('delete')) { console.log(` DELETE /api/${tableName}/:id - Delete ${tableName}`); } } if (endpoints) { console.log('đŸŽ¯ Custom endpoints: Available via endpoints function'); } // Actually start the HTTP server app.listen(serverPort, () => { console.log('✅ Fastfold server ready!'); }); }, getApp() { return this.app; }, async shutdown() { console.log('🛑 Shutting down Fastfold server...'); } }; return server; } /** * Generate HTML docs for Drizzle system */ static generateDrizzleDocs(adapter, tables) { const { ApiExplorer } = require('./docs'); // Generate documentation data const docs = this.generateDrizzleDocsJson(adapter, tables); // Create mock doc generator to use existing ApiExplorer const mockDocGenerator = { generateDocumentation: () => docs }; const explorer = new ApiExplorer(mockDocGenerator); return explorer.generateHtml(); } /** * Generate JSON docs for Drizzle system */ static generateDrizzleDocsJson(adapter, tables) { const tableNames = adapter.getTableNames().filter(name => tables[name]); // Generate table documentation const tablesDocs = {}; const endpoints = []; for (const tableName of tableNames) { const tableConfig = tables[tableName]; const ops = tableConfig.operations || ['create', 'read', 'update', 'delete']; // Get schema from Drizzle table definition let schema; try { const table = adapter.getTable(tableName); schema = this.extractDrizzleSchema(table); } catch (error) { // Fallback to mock schema if table not found schema = this.getMockTableSchema(tableName); } // Add table documentation tablesDocs[tableName] = { schema, security: this.getSecurityDescription(tableConfig.security) }; // Generate endpoints for this table endpoints.push(...this.generateDrizzleEndpoints(tableName, schema, ops)); } // Add health endpoint endpoints.unshift({ method: 'GET', path: '/health', description: 'Health check endpoint', responses: [ { status: 200, description: 'Server is healthy', schema: { type: 'object', properties: { status: { type: 'string', example: 'healthy' }, timestamp: { type: 'string', example: '2024-01-01T00:00:00.000Z' } } } } ] }); return { info: { title: 'Fastfold API with Drizzle', description: 'Auto-generated CRUD API with Drizzle ORM', version: '1.0.0', baseUrl: 'http://localhost:3001' }, tables: tablesDocs, endpoints }; } /** * Extract schema from Drizzle table definition */ static extractDrizzleSchema(table) { const schema = {}; // Handle mock database case - provide default schemas if (!table || !table._ || !table._.columns) { return this.getMockTableSchema(table); } // Real Drizzle table extraction const columns = table._.columns || {}; for (const [columnName, column] of Object.entries(columns)) { if (column && typeof column === 'object' && column.dataType) { schema[columnName] = this.mapDrizzleType(column.dataType); } else { // Fallback based on common column names if (columnName === 'id') schema[columnName] = 'number'; else if (columnName.includes('email')) schema[columnName] = 'string'; else if (columnName.includes('name') || columnName.includes('title')) schema[columnName] = 'string'; else if (columnName.includes('content') || columnName.includes('text')) schema[columnName] = 'text'; else if (columnName.includes('published') || columnName.includes('active')) schema[columnName] = 'boolean'; else if (columnName.includes('date') || columnName.includes('time')) schema[columnName] = 'date'; else schema[columnName] = 'string'; } } return schema; } /** * Get mock table schema for demo purposes */ static getMockTableSchema(tableName) { // Extract table name if it's an object with a name property const name = typeof tableName === 'string' ? tableName : tableName?.name || 'unknown'; // Return predefined schemas for known tables const mockSchemas = { users: { id: 'number', email: 'string', name: 'string', role: 'string', createdAt: 'date' }, posts: { id: 'number', title: 'string', content: 'text', published: 'boolean', authorId: 'number', createdAt: 'date' }, comments: { id: 'number', text: 'text', authorId: 'number', postId: 'number', createdAt: 'date' } }; return mockSchemas[name] || { id: 'number', name: 'string', createdAt: 'date' }; } /** * Map Drizzle data types to simple types */ static mapDrizzleType(drizzleType) { const type = drizzleType.toLowerCase(); if (type.includes('text') || type.includes('varchar')) return 'string'; if (type.includes('int') || type.includes('number')) return 'number'; if (type.includes('bool')) return 'boolean'; if (type.includes('date') || type.includes('time')) return 'date'; if (type.includes('json')) return 'json'; return 'string'; } /** * Generate endpoints documentation for a table */ static generateDrizzleEndpoints(tableName, schema, operations) { const endpoints = []; const basePath = `/api/${tableName}`; // GET /api/:table - List records if (operations.includes('read')) { endpoints.push({ method: 'GET', path: basePath, description: `List all ${tableName} records`, responses: [ { status: 200, description: 'List of records', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: { type: 'array', items: this.schemaToJsonSchema(schema) } } } } ] }); // GET /api/:table/:id - Get single record endpoints.push({ method: 'GET', path: `${basePath}/{id}`, description: `Get a specific ${tableName} record by ID`, parameters: [ { name: 'id', in: 'path', required: true, type: 'number', description: 'Record ID' } ], responses: [ { status: 200, description: 'Single record', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: this.schemaToJsonSchema(schema) } } }, { status: 404, description: 'Record not found' } ] }); } // POST /api/:table - Create record if (operations.includes('create')) { endpoints.push({ method: 'POST', path: basePath, description: `Create a new ${tableName} record`, requestBody: { description: `${tableName} data to create`, schema: this.schemaToJsonSchema(schema) }, responses: [ { status: 201, description: 'Record created successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: this.schemaToJsonSchema(schema) } } }, { status: 400, description: 'Validation error' } ] }); } // PUT /api/:table/:id - Update record if (operations.includes('update')) { endpoints.push({ method: 'PUT', path: `${basePath}/{id}`, description: `Update a ${tableName} record`, parameters: [ { name: 'id', in: 'path', required: true, type: 'number', description: 'Record ID' } ], requestBody: { description: `${tableName} data to update`, schema: this.schemaToJsonSchema(schema) }, responses: [ { status: 200, description: 'Record updated successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: this.schemaToJsonSchema(schema) } } }, { status: 404, description: 'Record not found' }, { status: 400, description: 'Validation error' } ] }); } // DELETE /api/:table/:id - Delete record if (operations.includes('delete')) { endpoints.push({ method: 'DELETE', path: `${basePath}/{id}`, description: `Delete a ${tableName} record`, parameters: [ { name: 'id', in: 'path', required: true, type: 'number', description: 'Record ID' } ], responses: [ { status: 200, description: 'Record deleted successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: { type: 'object', properties: { deleted: { type: 'boolean', example: true } } } } } }, { status: 404, description: 'Record not found' } ] }); } return endpoints; } /** * Convert schema to JSON Schema format */ static schemaToJsonSchema(schema) { const properties = {}; for (const [fieldName, fieldType] of Object.entries(schema)) { switch (fieldType.toLowerCase()) { case 'string': properties[fieldName] = { type: 'string', example: `sample_${fieldName}` }; break; case 'number': case 'integer': properties[fieldName] = { type: 'number', example: 42 }; break; case 'boolean': properties[fieldName] = { type: 'boolean', example: true }; break; case 'date': case 'datetime': properties[fieldName] = { type: 'string', format: 'date-time', example: '2024-01-01T00:00:00.000Z' }; break; case 'json': properties[fieldName] = { type: 'object', example: { key: 'value' } }; break; case 'text': properties[fieldName] = { type: 'string', example: `Sample ${fieldName} text` }; break; default: properties[fieldName] = { type: 'string', example: `sample_${fieldName}` }; } } return { type: 'object', properties, example: Object.fromEntries(Object.entries(properties).map(([key, value]) => [key, value.example])) }; } /** * Get security description */ static getSecurityDescription(security) { if (!security) return 'No security configured'; if (typeof security === 'function') return 'Custom security rule'; if (security.type === 'public') return 'Public access - no authentication required'; if (security.type === 'admin') return 'Admin only - requires admin role'; if (security.type === 'owner') return 'Owner-based access - users can only access their own records'; if (security.type === 'team') return 'Team-based access - users can access records from their team'; if (security.type === 'authenticated') return 'Authenticated users only'; return 'Custom security rule'; } } // Default export export default Fastfold; //# sourceMappingURL=index.js.map