Source

src/aws/s3/S3Service.js

/**
 * @module AWS Integration
 * @fileoverview S3Service - AWS S3 integration for file storage and retrieval.
 *
 * This service provides a clean abstraction over AWS S3 SDK for common file operations
 * used in the VectorIcons platform. Handles 750,000+ icon assets stored in S3 buckets
 * with CloudFront CDN distribution.
 *
 * **Production Use Cases:**
 * - Storing SVG source files for icons
 * - Storing generated image previews (PNG, WebP, etc.)
 * - Metadata storage for image processing pipelines
 * - CloudFront origin for global CDN delivery
 *
 * **Design Patterns:**
 * - Dependency injection (pass AWS.S3 client to constructor)
 * - Error wrapping (AWS errors → application errors)
 * - Promise-based API (not callbacks)
 * - Private client field (encapsulation)
 *
 * **Architecture Integration:**
 * ```
 * Lambda Functions (image processing)
 *      ↓
 * S3Service (this layer)
 *      ↓
 * AWS S3 SDK
 *      ↓
 * S3 Buckets → CloudFront CDN → Users
 * ```
 *
 * @example
 * // Basic usage
 * const s3 = new S3Service();
 *
 * // Upload icon SVG
 * await s3.upload('vectoricons-assets', 'icons/home.svg', svgBuffer);
 *
 * // Fetch for processing
 * const { Body } = await s3.fetch('vectoricons-assets', 'icons/home.svg');
 *
 * // Get metadata
 * const metadata = await s3.getObjectMetadata('vectoricons-assets', 'icons/home.svg');
 * console.log(metadata.ContentType); // 'image/svg+xml'
 *
 * @see {@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html AWS S3 SDK Documentation}
 */

const AWS = require('aws-sdk');
AWS.config.update({ region: 'us-east-1' });

/**
 * S3 service class providing file storage operations.
 *
 * Wraps AWS S3 SDK with application-specific error handling and logging.
 * Uses dependency injection for testability (pass mock S3 client in tests).
 *
 * **Key Features:**
 * - Upload files to S3 buckets
 * - Fetch files for processing
 * - Get object metadata (size, content-type, etc.)
 * - Get object tags (categorization, lifecycle rules)
 * - Verify bucket existence
 *
 * **Production Deployment:**
 * - Credentials via IAM roles (Lambda execution role)
 * - Bucket policies enforce encryption at rest
 * - CloudFront sits in front for global delivery
 * - S3 lifecycle policies move old files to Glacier
 *
 * @class S3Service
 */
class S3Service {

    #client;

    /**
     * Construct S3Service with AWS S3 client.
     *
     * Uses dependency injection to allow mocking in tests. In production,
     * credentials come from IAM role attached to Lambda execution role.
     *
     * @param {AWS.S3} [client] - AWS S3 client instance (default: new S3 client with us-east-1)
     *
     * @example
     * // Production usage (default client)
     * const s3 = new S3Service();
     *
     * @example
     * // Testing with mock client
     * const mockS3 = {
     *   getObject: jest.fn().mockReturnValue({ promise: () => Promise.resolve(...) }),
     *   upload: jest.fn().mockReturnValue({ promise: () => Promise.resolve(...) })
     * };
     * const s3 = new S3Service(mockS3);
     */
    constructor(client = new AWS.S3({ region: 'us-east-1' })) {
        this.client = client;
    }

    get client() {
        return this.#client;
    }

    set client(value) {
        this.#client = value;
    }

    /**
     * Fetch an object from S3.
     *
     * Retrieves file contents from S3 bucket. Used for downloading icon assets,
     * reading metadata files, or fetching files for processing in Lambda functions.
     *
     * @param {string} bucket - S3 bucket name (e.g., 'vectoricons-assets')
     * @param {string} key - S3 object key (e.g., 'icons/home.svg')
     * @returns {Promise<AWS.S3.GetObjectOutput>} S3 object data with Body, ContentType, etc.
     * @throws {Error} If object doesn't exist or permission denied
     *
     * @example
     * const { Body, ContentType } = await s3.fetch('vectoricons-assets', 'icons/home.svg');
     * const svgContent = Body.toString('utf-8');
     * console.log(ContentType); // 'image/svg+xml'
     */
    async fetch(bucket, key) {
        try {
            const params = { Bucket: bucket, Key: key };
            const objectData = await this.client.getObject(params).promise();
            console.log(`Fetched object from S3: ${bucket}/${key}`);
            return objectData;
        }
        catch (error) {
            console.error('Error fetching object from S3:', error);
            throw new Error('Failed to fetch object from S3');
        }
    }

    /**
     * Get tags for an S3 object.
     *
     * Tags are used for categorization, cost allocation, and lifecycle rules.
     * Common tags: environment (prod/staging), asset-type (icon/illustration),
     * processing-status (pending/complete).
     *
     * @param {string} bucket - S3 bucket name
     * @param {string} key - S3 object key
     * @returns {Promise<AWS.S3.TagSet>} Array of { Key, Value } tag objects
     * @throws {Error} If object doesn't exist or permission denied
     *
     * @example
     * const tags = await s3.getObjectTags('vectoricons-assets', 'icons/home.svg');
     * // [{ Key: 'asset-type', Value: 'icon' }, { Key: 'status', Value: 'processed' }]
     * const assetType = tags.find(t => t.Key === 'asset-type')?.Value;
     */
    async getObjectTags(bucket, key) {
        try {
            const params = { Bucket: bucket, Key: key };
            const tags = await this.client.getObjectTagging(params).promise();
            console.log(`Fetched tags for object ${bucket}/${key}`);
            return tags.TagSet;
        }
        catch (error) {
            console.error('Error fetching tags from S3:', error);
            throw new Error('Failed to fetch tags from S3');
        }
    }

    /**
     * Get metadata for an S3 object without downloading the body.
     *
     * Uses HEAD request (fast, no data transfer) to get object metadata like
     * content type, size, last modified date. Useful for checking if object
     * exists or getting info before download.
     *
     * @param {string} bucket - S3 bucket name
     * @param {string} key - S3 object key
     * @returns {Promise<AWS.S3.HeadObjectOutput>} Object metadata (ContentType, ContentLength, LastModified, etc.)
     * @throws {Error} If object doesn't exist or permission denied
     *
     * @example
     * const metadata = await s3.getObjectMetadata('vectoricons-assets', 'icons/home.svg');
     * console.log(metadata.ContentType);   // 'image/svg+xml'
     * console.log(metadata.ContentLength); // 1024 (bytes)
     * console.log(metadata.LastModified);  // Date object
     */
    async getObjectMetadata(bucket, key) {
        try {
            const params = { Bucket: bucket, Key: key };
            const metadata = await this.client.headObject(params).promise();
            return metadata;
        }
        catch (error) {
            console.error('Error fetching metadata from S3:', error);
            throw new Error('Failed to fetch metadata from S3');
        }
    }

    /**
     * Upload a file to S3.
     *
     * Uploads file contents to S3 bucket. Supports buffers, streams, and strings.
     * Uses multipart upload automatically for large files (>5MB).
     *
     * **Production Pattern:**
     * After upload, CloudFront CDN serves files globally with low latency.
     * S3 lifecycle policies move old files to cheaper storage (Glacier).
     *
     * @param {string} bucket - S3 bucket name (e.g., 'vectoricons-assets')
     * @param {string} key - S3 object key (e.g., 'icons/home.svg')
     * @param {Buffer|Stream|string} body - File contents to upload
     * @returns {Promise<AWS.S3.ManagedUpload.SendData>} Upload result with Location, ETag, Bucket, Key
     * @throws {Error} If upload fails or permission denied
     *
     * @example
     * // Upload buffer
     * const svgBuffer = Buffer.from('<svg>...</svg>', 'utf-8');
     * const result = await s3.upload('vectoricons-assets', 'icons/new-icon.svg', svgBuffer);
     * console.log(result.Location); // 'https://vectoricons-assets.s3.amazonaws.com/icons/new-icon.svg'
     *
     * @example
     * // Upload stream (memory efficient for large files)
     * const fileStream = fs.createReadStream('/path/to/large-file.zip');
     * await s3.upload('vectoricons-assets', 'uploads/batch-123.zip', fileStream);
     */
    async upload(bucket, key, body) {
        try {
            const params = { Bucket: bucket, Key: key, Body: body };
            const uploadResult = await this.client.upload(params).promise();
            console.log(`Uploaded object to S3: ${bucket}/${key}`);
            return uploadResult;
        }
        catch (error) {
            console.error('Error uploading object to S3:', error);
            throw new Error('Failed to upload object to S3');
        }
    }

    /**
     * Verify that an S3 bucket exists and is accessible.
     *
     * Uses HEAD request to check bucket existence without listing objects.
     * Useful for health checks or validation before batch operations.
     *
     * @param {string} bucket - S3 bucket name
     * @returns {Promise<AWS.S3.HeadBucketOutput>} Empty object (success = bucket exists)
     * @throws {Error} If bucket doesn't exist or access denied
     *
     * @example
     * try {
     *   await s3.headBucket('vectoricons-assets');
     *   console.log('Bucket exists and is accessible');
     * } catch (error) {
     *   console.error('Bucket not found or no permission');
     * }
     */
    async headBucket(bucket) {
        try {
            const params = { Bucket: bucket };
            const result = await this.client.headBucket(params).promise();
            console.log(`Verified S3 bucket exists: ${bucket}`);
            return result;
        }
        catch (error) {
            console.error(`Error verifying S3 bucket ${bucket}:`, error);
            throw new Error(`Failed to verify S3 bucket: ${bucket}`);
        }
    }
}

module.exports = S3Service;