Learning Objectives

  • Set up the project structure
  • Build core Promise utilities
  • Implement API integration layer
  • Create the base aggregator class

Project Setup

Initialize Project

mkdir data-aggregator
cd data-aggregator
npm init -y

Install Dependencies

npm install --save-dev jest

Update package.json

{
  "name": "data-aggregator",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "test": "jest",
    "start": "node src/index.js"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}

Core Utilities

src/utils/delay.js

/**
 * Create a Promise that resolves after specified milliseconds
 */
export function delay(ms, value) {
  return new Promise(resolve => {
    setTimeout(() => resolve(value), ms);
  });
}

/**
 * Create a delay with cancellation support
 */
export function cancellableDelay(ms, signal) {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => resolve(), ms);
    
    if (signal) {
      signal.addEventListener('abort', () => {
        clearTimeout(timeoutId);
        reject(new Error('Cancelled'));
      });
    }
  });
}

src/utils/timeout.js

/**
 * Add timeout to any Promise
 */
export function timeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
    })
  ]);
}

/**
 * Timeout with custom error
 */
export function timeoutWithError(promise, ms, errorMessage) {
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      setTimeout(() => reject(new Error(errorMessage)), ms);
    })
  ]);
}

src/utils/retry.js

import { delay } from './delay.js';

/**
 * Retry failed operations with exponential backoff
 */
export async function retry(
  fn,
  options = {}
) {
  const {
    retries = 3,
    baseDelay = 1000,
    maxDelay = 30000,
    onRetry = null
  } = options;
  
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === retries - 1) {
        throw error;
      }
      
      const delayTime = Math.min(
        baseDelay * Math.pow(2, attempt),
        maxDelay
      );
      
      if (onRetry) {
        onRetry(attempt + 1, retries, error);
      }
      
      await delay(delayTime);
    }
  }
}

/**
 * Retry with jitter
 */
export async function retryWithJitter(fn, options = {}) {
  const {
    retries = 3,
    baseDelay = 1000,
    maxDelay = 30000
  } = options;
  
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === retries - 1) {
        throw error;
      }
      
      const exponentialDelay = baseDelay * Math.pow(2, attempt);
      const jitter = Math.random() * exponentialDelay;
      const delayTime = Math.min(exponentialDelay + jitter, maxDelay);
      
      await delay(delayTime);
    }
  }
}

API Client Base

src/api/client.js

import { timeout } from '../utils/timeout.js';
import { retry } from '../utils/retry.js';

export class APIClient {
  constructor(baseURL, options = {}) {
    this.baseURL = baseURL;
    this.timeout = options.timeout || 5000;
    this.retries = options.retries || 3;
    this.headers = options.headers || {};
  }
  
  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    
    const fetchWithTimeout = timeout(
      fetch(url, {
        ...options,
        headers: {
          'Content-Type': 'application/json',
          ...this.headers,
          ...options.headers
        }
      }),
      this.timeout
    );
    
    return await retry(
      async () => {
        const response = await fetchWithTimeout;
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        return await response.json();
      },
      {
        retries: this.retries,
        onRetry: (attempt, total, error) => {
          console.log(`Retry ${attempt}/${total}: ${error.message}`);
        }
      }
    );
  }
  
  async get(endpoint, options = {}) {
    return await this.request(endpoint, {
      ...options,
      method: 'GET'
    });
  }
  
  async post(endpoint, data, options = {}) {
    return await this.request(endpoint, {
      ...options,
      method: 'POST',
      body: JSON.stringify(data)
    });
  }
}

Mock API Implementations

src/api/weatherAPI.js

import { APIClient } from './client.js';
import { delay } from '../utils/delay.js';

export class WeatherAPI extends APIClient {
  constructor() {
    super('https://api.weather.example.com', {
      timeout: 3000,
      retries: 2
    });
  }
  
  async getWeather(city) {
    // Simulate API call
    await delay(Math.random() * 1000);
    
    return {
      city,
      temperature: Math.round(Math.random() * 30 + 10),
      condition: ['Sunny', 'Cloudy', 'Rainy'][Math.floor(Math.random() * 3)],
      humidity: Math.round(Math.random() * 100),
      timestamp: new Date().toISOString()
    };
  }
}

src/api/newsAPI.js

import { APIClient } from './client.js';
import { delay } from '../utils/delay.js';

export class NewsAPI extends APIClient {
  constructor() {
    super('https://api.news.example.com', {
      timeout: 3000,
      retries: 2
    });
  }
  
  async getHeadlines(category = 'general') {
    // Simulate API call
    await delay(Math.random() * 1000);
    
    return {
      category,
      articles: [
        {
          title: 'Breaking News 1',
          source: 'News Source',
          publishedAt: new Date().toISOString()
        },
        {
          title: 'Breaking News 2',
          source: 'News Source',
          publishedAt: new Date().toISOString()
        }
      ],
      timestamp: new Date().toISOString()
    };
  }
}

src/api/stocksAPI.js

import { APIClient } from './client.js';
import { delay } from '../utils/delay.js';

export class StocksAPI extends APIClient {
  constructor() {
    super('https://api.stocks.example.com', {
      timeout: 3000,
      retries: 2
    });
  }
  
  async getQuote(symbol) {
    // Simulate API call
    await delay(Math.random() * 1000);
    
    const basePrice = 100;
    const change = (Math.random() - 0.5) * 10;
    
    return {
      symbol,
      price: (basePrice + change).toFixed(2),
      change: change.toFixed(2),
      changePercent: ((change / basePrice) * 100).toFixed(2),
      volume: Math.floor(Math.random() * 1000000),
      timestamp: new Date().toISOString()
    };
  }
}

Base Aggregator

src/aggregator/index.js

import { WeatherAPI } from '../api/weatherAPI.js';
import { NewsAPI } from '../api/newsAPI.js';
import { StocksAPI } from '../api/stocksAPI.js';

export class DataAggregator {
  constructor() {
    this.weatherAPI = new WeatherAPI();
    this.newsAPI = new NewsAPI();
    this.stocksAPI = new StocksAPI();
  }
  
  async fetchAll(config) {
    const { city, newsCategory, stockSymbol } = config;
    
    try {
      const [weather, news, stocks] = await Promise.all([
        this.weatherAPI.getWeather(city),
        this.newsAPI.getHeadlines(newsCategory),
        this.stocksAPI.getQuote(stockSymbol)
      ]);
      
      return {
        success: true,
        data: {
          weather,
          news,
          stocks
        },
        timestamp: new Date().toISOString()
      };
    } catch (error) {
      return {
        success: false,
        error: error.message,
        timestamp: new Date().toISOString()
      };
    }
  }
  
  async fetchWithFallback(config) {
    const results = await Promise.allSettled([
      this.weatherAPI.getWeather(config.city),
      this.newsAPI.getHeadlines(config.newsCategory),
      this.stocksAPI.getQuote(config.stockSymbol)
    ]);
    
    return {
      weather: results[0].status === 'fulfilled' ? results[0].value : null,
      news: results[1].status === 'fulfilled' ? results[1].value : null,
      stocks: results[2].status === 'fulfilled' ? results[2].value : null,
      timestamp: new Date().toISOString()
    };
  }
}

Main Entry Point

src/index.js

import { DataAggregator } from './aggregator/index.js';

async function main() {
  const aggregator = new DataAggregator();
  
  const config = {
    city: 'London',
    newsCategory: 'technology',
    stockSymbol: 'AAPL'
  };
  
  console.log('Fetching data...\n');
  
  const result = await aggregator.fetchAll(config);
  
  if (result.success) {
    console.log('✅ Data fetched successfully!\n');
    console.log('Weather:', result.data.weather);
    console.log('\nNews:', result.data.news);
    console.log('\nStocks:', result.data.stocks);
  } else {
    console.error('❌ Failed to fetch data:', result.error);
  }
}

main().catch(console.error);

Testing Setup

tests/utils/delay.test.js

import { delay } from '../../src/utils/delay.js';

describe('delay', () => {
  test('resolves after specified time', async () => {
    const start = Date.now();
    await delay(100);
    const elapsed = Date.now() - start;
    
    expect(elapsed).toBeGreaterThanOrEqual(100);
    expect(elapsed).toBeLessThan(150);
  });
  
  test('resolves with value', async () => {
    const value = await delay(10, 'test');
    expect(value).toBe('test');
  });
});

Running the Project

# Run the application
npm start

# Run tests
npm test

Key Takeaways

  • ✅ Project structure organized by functionality
  • ✅ Core utilities for delay, timeout, and retry
  • ✅ Base API client with error handling
  • ✅ Mock API implementations for testing
  • ✅ Data aggregator with parallel fetching
  • ✅ Test setup with Jest

Next Steps

In Part 2, we'll add caching, rate limiting, and advanced error handling!