Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
import type { Plugin } from 'vite';
|
|
import { lowdbService } from './lowdbService';
|
|
import {
|
|
ValidationError,
|
|
validateRequired,
|
|
validateFileName,
|
|
validateRecordForInsert,
|
|
validateRecordForUpdate
|
|
} from './validation';
|
|
import Papa from 'papaparse';
|
|
import { buildAttachmentContentDisposition } from './utils/contentDisposition';
|
|
|
|
/**
|
|
* Error response interface
|
|
*/
|
|
interface ErrorResponse {
|
|
error: string;
|
|
code: string;
|
|
details?: any;
|
|
timestamp: string;
|
|
}
|
|
|
|
/**
|
|
* Custom error class for API errors
|
|
*/
|
|
class APIError extends Error {
|
|
constructor(
|
|
public statusCode: number,
|
|
public code: string,
|
|
message: string,
|
|
public details?: any
|
|
) {
|
|
super(message);
|
|
this.name = 'APIError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Data Management API Plugin
|
|
* Provides RESTful API endpoints for managing data tables
|
|
*/
|
|
export function dataManagementApiPlugin(): Plugin {
|
|
return {
|
|
name: 'data-management-api',
|
|
|
|
async configureServer(server) {
|
|
// Initialize the database
|
|
await lowdbService.initialize();
|
|
|
|
// Helper function to parse JSON body
|
|
const parseBody = (req: any): Promise<any> => {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', (chunk: any) => body += chunk);
|
|
req.on('end', () => {
|
|
try {
|
|
resolve(body ? JSON.parse(body) : {});
|
|
} catch (e) {
|
|
reject(new APIError(400, 'INVALID_JSON', 'Invalid JSON in request body'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
};
|
|
|
|
// Helper function to send JSON response
|
|
const sendJSON = (res: any, statusCode: number, data: any) => {
|
|
res.statusCode = statusCode;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify(data));
|
|
};
|
|
|
|
// Helper function to send error response
|
|
const sendError = (res: any, statusCode: number, message: string, code?: string, details?: any) => {
|
|
const errorResponse: ErrorResponse = {
|
|
error: message,
|
|
code: code || 'ERROR',
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
if (details) {
|
|
errorResponse.details = details;
|
|
}
|
|
|
|
sendJSON(res, statusCode, errorResponse);
|
|
};
|
|
|
|
// Error logging function
|
|
const logError = (error: Error | APIError, context: string) => {
|
|
const timestamp = new Date().toISOString();
|
|
|
|
if (error instanceof APIError) {
|
|
console.error(`[${timestamp}] [Data Management API] ${context}:`, {
|
|
statusCode: error.statusCode,
|
|
code: error.code,
|
|
message: error.message,
|
|
details: error.details
|
|
});
|
|
} else {
|
|
console.error(`[${timestamp}] [Data Management API] ${context}:`, {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack
|
|
});
|
|
}
|
|
};
|
|
|
|
// Validation helper functions
|
|
const validateRequiredField = (value: any, fieldName: string) => {
|
|
try {
|
|
validateRequired(value, fieldName);
|
|
} catch (e: any) {
|
|
throw new APIError(400, 'VALIDATION_ERROR', e.message);
|
|
}
|
|
};
|
|
|
|
const validateFileNameParam = (fileName: string) => {
|
|
try {
|
|
validateFileName(fileName);
|
|
} catch (e: any) {
|
|
throw new APIError(400, 'VALIDATION_ERROR', e.message);
|
|
}
|
|
};
|
|
|
|
const validateRecordDataForInsert = (data: any) => {
|
|
try {
|
|
validateRecordForInsert(data);
|
|
} catch (e: any) {
|
|
if (e instanceof ValidationError) {
|
|
throw new APIError(400, 'VALIDATION_ERROR', e.message, e.details);
|
|
}
|
|
throw new APIError(400, 'VALIDATION_ERROR', e.message);
|
|
}
|
|
};
|
|
|
|
const validateRecordDataForUpdate = (data: any) => {
|
|
try {
|
|
validateRecordForUpdate(data);
|
|
} catch (e: any) {
|
|
if (e instanceof ValidationError) {
|
|
throw new APIError(400, 'VALIDATION_ERROR', e.message, e.details);
|
|
}
|
|
throw new APIError(400, 'VALIDATION_ERROR', e.message);
|
|
}
|
|
};
|
|
|
|
// Main middleware for data management API
|
|
server.middlewares.use(async (req: any, res: any, next: any) => {
|
|
// Only handle /api/data/* routes
|
|
if (!req.url.startsWith('/api/data')) {
|
|
return next();
|
|
}
|
|
|
|
try {
|
|
// Parse URL
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const pathname = url.pathname;
|
|
|
|
// Route: GET /api/data/tables - Get all tables
|
|
if (pathname === '/api/data/tables' && req.method === 'GET') {
|
|
const tables = await lowdbService.getAllTables();
|
|
sendJSON(res, 200, tables);
|
|
return;
|
|
}
|
|
|
|
// Route: POST /api/data/tables - Create new table
|
|
if (pathname === '/api/data/tables' && req.method === 'POST') {
|
|
const body = await parseBody(req);
|
|
const { tableName } = body;
|
|
|
|
validateRequiredField(tableName, 'tableName');
|
|
|
|
// Generate fileName from tableName if not provided
|
|
// Use tableName directly as fileName (supports Chinese)
|
|
const fileName = tableName.trim();
|
|
validateFileNameParam(fileName);
|
|
|
|
// Check if table already exists
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (exists) {
|
|
throw new APIError(400, 'TABLE_EXISTS', `Table '${tableName}' already exists`);
|
|
}
|
|
|
|
await lowdbService.createTable(fileName, tableName);
|
|
sendJSON(res, 201, { success: true, fileName, tableName });
|
|
return;
|
|
}
|
|
|
|
// Route: PUT /api/data/tables/:fileName - Update table info (e.g., tableName)
|
|
const updateTableMatch = pathname.match(/^\/api\/data\/tables\/([^/]+)$/);
|
|
if (updateTableMatch && req.method === 'PUT') {
|
|
const fileName = decodeURIComponent(updateTableMatch[1]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
const body = await parseBody(req);
|
|
const { tableName } = body;
|
|
|
|
if (tableName !== undefined) {
|
|
await lowdbService.updateTableName(fileName, tableName);
|
|
}
|
|
|
|
sendJSON(res, 200, { success: true, fileName, tableName });
|
|
return;
|
|
}
|
|
|
|
// Route: DELETE /api/data/tables/:fileName - Delete table
|
|
const deleteTableMatch = pathname.match(/^\/api\/data\/tables\/([^/]+)$/);
|
|
if (deleteTableMatch && req.method === 'DELETE') {
|
|
const fileName = decodeURIComponent(deleteTableMatch[1]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
await lowdbService.deleteTable(fileName);
|
|
sendJSON(res, 200, { success: true });
|
|
return;
|
|
}
|
|
|
|
// Route: GET /api/data/:fileName/export - CSV Export (must be before :id route)
|
|
const exportMatch = pathname.match(/^\/api\/data\/([^/]+)\/export$/);
|
|
if (exportMatch && req.method === 'GET') {
|
|
const fileName = decodeURIComponent(exportMatch[1]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
// Get all records
|
|
const records = await lowdbService.getTable(fileName);
|
|
|
|
// Convert to CSV
|
|
const csv = Papa.unparse(records, {
|
|
quotes: true,
|
|
header: true
|
|
});
|
|
|
|
// Send CSV response
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
res.setHeader('Content-Disposition', buildAttachmentContentDisposition(`${fileName}.csv`));
|
|
res.end(csv);
|
|
return;
|
|
}
|
|
|
|
// Route: GET /api/data/:fileName - Get all data from table
|
|
const getTableMatch = pathname.match(/^\/api\/data\/([^/]+)$/);
|
|
if (getTableMatch && req.method === 'GET') {
|
|
const fileName = decodeURIComponent(getTableMatch[1]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
const records = await lowdbService.getTable(fileName);
|
|
sendJSON(res, 200, records);
|
|
return;
|
|
}
|
|
|
|
// Route: GET /api/data/:fileName/:id - Get single record
|
|
const getRecordMatch = pathname.match(/^\/api\/data\/([^/]+)\/([^/]+)$/);
|
|
if (getRecordMatch && req.method === 'GET') {
|
|
const fileName = decodeURIComponent(getRecordMatch[1]);
|
|
const id = decodeURIComponent(getRecordMatch[2]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
const record = await lowdbService.getRecord(fileName, id);
|
|
if (!record) {
|
|
throw new APIError(404, 'NOT_FOUND', `Record with id '${id}' not found`);
|
|
}
|
|
|
|
sendJSON(res, 200, record);
|
|
return;
|
|
}
|
|
|
|
// Route: POST /api/data/:fileName - Insert new record
|
|
const insertMatch = pathname.match(/^\/api\/data\/([^/]+)$/);
|
|
if (insertMatch && req.method === 'POST') {
|
|
const fileName = decodeURIComponent(insertMatch[1]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
const body = await parseBody(req);
|
|
validateRecordDataForInsert(body);
|
|
|
|
const newRecord = await lowdbService.insertData(fileName, body);
|
|
sendJSON(res, 201, newRecord);
|
|
return;
|
|
}
|
|
|
|
// Route: PUT /api/data/:fileName/:id - Update record
|
|
const updateMatch = pathname.match(/^\/api\/data\/([^/]+)\/([^/]+)$/);
|
|
if (updateMatch && req.method === 'PUT') {
|
|
const fileName = decodeURIComponent(updateMatch[1]);
|
|
const id = decodeURIComponent(updateMatch[2]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
const body = await parseBody(req);
|
|
validateRecordDataForUpdate(body);
|
|
|
|
try {
|
|
const updatedRecord = await lowdbService.updateData(fileName, id, body);
|
|
sendJSON(res, 200, updatedRecord);
|
|
} catch (e: any) {
|
|
if (e.message.includes('not found')) {
|
|
throw new APIError(404, 'NOT_FOUND', `Record with id '${id}' not found`);
|
|
}
|
|
throw e;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Route: DELETE /api/data/:fileName/:id - Delete record
|
|
const deleteMatch = pathname.match(/^\/api\/data\/([^/]+)\/([^/]+)$/);
|
|
if (deleteMatch && req.method === 'DELETE') {
|
|
const fileName = decodeURIComponent(deleteMatch[1]);
|
|
const id = decodeURIComponent(deleteMatch[2]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
try {
|
|
await lowdbService.deleteData(fileName, id);
|
|
sendJSON(res, 200, { success: true });
|
|
} catch (e: any) {
|
|
if (e.message.includes('not found')) {
|
|
throw new APIError(404, 'NOT_FOUND', `Record with id '${id}' not found`);
|
|
}
|
|
throw e;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Route: POST /api/data/:fileName/import - CSV Import
|
|
const importMatch = pathname.match(/^\/api\/data\/([^/]+)\/import$/);
|
|
if (importMatch && req.method === 'POST') {
|
|
const fileName = decodeURIComponent(importMatch[1]);
|
|
validateFileNameParam(fileName);
|
|
|
|
const exists = await lowdbService.tableExists(fileName);
|
|
if (!exists) {
|
|
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
|
|
}
|
|
|
|
const body = await parseBody(req);
|
|
const { csvData } = body;
|
|
|
|
if (!csvData || typeof csvData !== 'string') {
|
|
throw new APIError(400, 'VALIDATION_ERROR', 'Missing or invalid csvData parameter');
|
|
}
|
|
|
|
// Parse CSV
|
|
const parseResult = Papa.parse(csvData, {
|
|
header: true,
|
|
dynamicTyping: true,
|
|
skipEmptyLines: true,
|
|
transformHeader: (header) => header.trim()
|
|
});
|
|
|
|
if (parseResult.errors.length > 0) {
|
|
throw new APIError(
|
|
400,
|
|
'CSV_PARSE_ERROR',
|
|
'Failed to parse CSV file',
|
|
{ errors: parseResult.errors }
|
|
);
|
|
}
|
|
|
|
// Import data with upsert logic
|
|
const importedRecords = await lowdbService.importCSV(fileName, parseResult.data);
|
|
sendJSON(res, 200, {
|
|
success: true,
|
|
recordCount: importedRecords.length,
|
|
records: importedRecords
|
|
});
|
|
return;
|
|
}
|
|
|
|
// No route matched
|
|
throw new APIError(404, 'NOT_FOUND', 'API endpoint not found');
|
|
|
|
} catch (e: any) {
|
|
// Error handling middleware
|
|
if (e instanceof APIError) {
|
|
// Known API error
|
|
logError(e, `${req.method} ${req.url}`);
|
|
sendError(res, e.statusCode, e.message, e.code, e.details);
|
|
} else if (e instanceof ValidationError) {
|
|
// Validation error from lowdbService
|
|
logError(e, `${req.method} ${req.url}`);
|
|
sendError(res, 400, e.message, 'VALIDATION_ERROR', e.details);
|
|
} else if (e.code === 'ENOENT') {
|
|
// File not found error
|
|
logError(e, `${req.method} ${req.url}`);
|
|
sendError(res, 404, 'Resource not found', 'NOT_FOUND');
|
|
} else if (e.code === 'EACCES') {
|
|
// Permission error
|
|
logError(e, `${req.method} ${req.url}`);
|
|
sendError(res, 500, 'Permission denied', 'PERMISSION_ERROR');
|
|
} else {
|
|
// Unknown error
|
|
logError(e, `${req.method} ${req.url}`);
|
|
sendError(res, 500, 'Internal server error', 'SERVER_ERROR', {
|
|
message: e.message
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
}
|