import ApiError from './ApiError'; interface ParsedFormData { fields: Record; files: Array<{ fieldName: string; fileName: string; contentType: string; data: Buffer; }>; } /** * Parse multipart/form-data from Lambda event * Supports both base64 encoded and binary body */ export function parseMultipartFormData( eventBody: string | null, contentType: string | undefined, isBase64Encoded: boolean = false ): ParsedFormData { if (!eventBody) { throw new ApiError(400, 'Request body is required'); } if (!contentType || !contentType.includes('multipart/form-data')) { throw new ApiError(400, 'Content-Type must be multipart/form-data'); } // Extract boundary from Content-Type header const boundaryMatch = contentType.match(/boundary=([^;]+)/); if (!boundaryMatch) { throw new ApiError(400, 'Invalid multipart boundary'); } const boundary = boundaryMatch[1].trim(); // Decode base64 body if needed (API Gateway sends base64 encoded for binary media types) let bodyBuffer: Buffer; try { if (isBase64Encoded) { bodyBuffer = Buffer.from(eventBody, 'base64'); } else { // Try to detect if it's base64 if (eventBody.match(/^[A-Za-z0-9+/=]+$/)) { bodyBuffer = Buffer.from(eventBody, 'base64'); } else { bodyBuffer = Buffer.from(eventBody, 'binary'); } } } catch (error) { throw new ApiError(400, 'Invalid request body encoding'); } // Split by boundary const parts = bodyBuffer.toString('binary').split(`--${boundary}`); const fields: Record = {}; const files: ParsedFormData['files'] = []; for (const part of parts) { if (!part || part.trim() === '' || part.trim() === '--') { continue; } // Split headers and body const [headers, ...bodyParts] = part.split('\r\n\r\n'); if (!headers || bodyParts.length === 0) { continue; } const body = bodyParts.join('\r\n\r\n').trim(); if (!body) { continue; } // Parse Content-Disposition header const contentDispositionMatch = headers.match(/Content-Disposition:\s*form-data;\s*name="([^"]+)"/); if (!contentDispositionMatch) { continue; } const fieldName = contentDispositionMatch[1]; // Check if it's a file const filenameMatch = headers.match(/filename="([^"]+)"/); const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/); if (filenameMatch) { // It's a file const fileName = filenameMatch[1]; const fileContentType = contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream'; // Convert body to buffer (remove trailing boundary markers) const fileData = Buffer.from(body.replace(/\r\n--$/, ''), 'binary'); files.push({ fieldName, fileName, contentType: fileContentType, data: fileData, }); } else { // It's a regular field fields[fieldName] = body.replace(/\r\n--$/, '').trim(); } } return { fields, files }; } /** * Parse JSON field from form data */ export function parseJsonField(fields: Record, fieldName: string): any { const value = fields[fieldName]; if (!value) { return null; } try { return JSON.parse(value); } catch (error) { throw new ApiError(400, `Invalid JSON in field: ${fieldName}`); } }