Skip to content

Commit

Permalink
Merge pull request #20 from phuchoa2001/main
Browse files Browse the repository at this point in the history
Add API handling module with caching, retry, and error handling
  • Loading branch information
deepakkumar55 authored Jul 7, 2024
2 parents 995a239 + b4bd69b commit 6d4fd34
Show file tree
Hide file tree
Showing 4 changed files with 294 additions and 68 deletions.
179 changes: 179 additions & 0 deletions Basic Projects/6-currency_converter/assets/js/ApiClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Constants
const DEFAULT_METHOD = 'GET';
const DEFAULT_CACHE_DURATION = 0;
const DEFAULT_RETRIES = 0;
const DEFAULT_RETRY_DELAY = 2000;
const DEFAULT_TIMEOUT = 10000;
const NO_CACHE = 'no-cache';
const CACHE_KEY = 'cacheData';

const noop = () => {};

class CacheManager {
constructor() {
this.storage = window.localStorage;
this.cacheKey = CACHE_KEY;
if (!this.storage.getItem(this.cacheKey)) {
this.storage.setItem(this.cacheKey, JSON.stringify({}));
}
}

get(key, duration = DEFAULT_CACHE_DURATION) {
const cache = JSON.parse(this.storage.getItem(this.cacheKey));
const item = cache[key];
if (item && (Date.now() - item.timestamp < duration)) {
return item.data;
} else if (item) {
delete cache[key];
this.storage.setItem(this.cacheKey, JSON.stringify(cache));
}
return null;
}

set(key, data) {
const cache = JSON.parse(this.storage.getItem(this.cacheKey));
cache[key] = {
data: data,
timestamp: Date.now()
};
this.storage.setItem(this.cacheKey, JSON.stringify(cache));
}

clear() {
this.storage.setItem(this.cacheKey, JSON.stringify({}));
}
}

class FetchManager {
constructor() {
this.controller = new AbortController();
}

async fetch(url, options) {
const {
method = DEFAULT_METHOD,
data,
params,
retries = DEFAULT_RETRIES,
retryDelay = DEFAULT_RETRY_DELAY
} = options;
const fetchOptions = this.createFetchOptions(method, data);

for (let i = 0; i <= retries; i++) {
try {
const fullUrl = this.createFullUrl(url, method, params);
const response = await fetch(fullUrl, fetchOptions);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

return await response.json();
} catch (error) {
if (i === retries) throw error;
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}

createFetchOptions(method, data) {
return {
method,
signal: this.controller.signal,
cache: NO_CACHE,
body: method !== DEFAULT_METHOD ? JSON.stringify(data) : undefined,
};
}

createFullUrl(url, method, params) {
if (method === DEFAULT_METHOD && Object.keys(params).length > 0) {
return `${url}?${new URLSearchParams(params)}`;
}
return url;
}

cancelRequest(message = 'Request canceled by the user') {
this.controller.abort(message);
this.controller = new AbortController();
}
}

class ResponseHandler {
handleSuccess(data, onSuccess = noop) {
onSuccess(data);
return { success: true, data };
}

handleFailure(error, onFailure = noop) {
if (error.name === 'AbortError') {
console.log('Request canceled', error.message);
} else {
onFailure(error);
}
return { success: false, error };
}
}

class ApiClient {
constructor() {
this.cacheManager = new CacheManager();
this.fetchManager = new FetchManager();
this.responseHandler = new ResponseHandler();
}

async request(url, options = {}) {
const {
method = DEFAULT_METHOD,
data = {},
params = {},
useCache = true,
cacheDuration = DEFAULT_CACHE_DURATION,
onWait = noop,
onSuccess = noop,
onFailure = noop,
onComplete = noop
} = options;

const cacheKey = this.getCacheKey(url, method, data, params);

onWait();

if (useCache) {
const cachedData = this.cacheManager.get(cacheKey, cacheDuration);
if (cachedData) {
onSuccess(cachedData);
onComplete();
return { success: true, data: cachedData };
}
}

try {
const result = await this.fetchManager.fetch(url, { method, data, params, ...options });

if (useCache) {
this.cacheManager.set(cacheKey, result);
}

onComplete();
return this.responseHandler.handleSuccess(result, onSuccess);
} catch (error) {
onComplete();
return this.responseHandler.handleFailure(error, onFailure);
}
}

getCacheKey(url, method, data, params) {
return `${method}_${url}_${JSON.stringify(data)}_${JSON.stringify(params)}`;
}

cancelRequest(message) {
this.fetchManager.cancelRequest(message);
}

clearCache() {
this.cacheManager.clear();
}
}

const apiClient = new ApiClient();
export default apiClient;
167 changes: 100 additions & 67 deletions Basic Projects/6-currency_converter/assets/js/CurrencyConverter.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,85 +6,118 @@ import {
FALLBACK_CURRENCIES,
MAX_DECIMAL_PLACES
} from './constants.js';
import apiClient from './ApiClient.js';

class CurrencyConverter {
constructor() {
this.amountInput = $('#amount');
this.fromCurrencySelect = $('#fromCurrency');
this.toCurrencySelect = $('#toCurrency');
this.resultInput = $('#result');
this.convertBtn = $('#convertBtn');
this.currencies = [];
this.cache = {};
}
constructor() {
this.amountInput = $('#amount');
this.fromCurrencySelect = $('#fromCurrency');
this.toCurrencySelect = $('#toCurrency');
this.resultInput = $('#result');
this.convertBtn = $('#convertBtn');
this.currencies = [];
}

async init() {
await this.fetchSupportedCurrencies();
this.populateCurrencySelects();
this.setupEventListeners();
}
async init() {
await this.fetchSupportedCurrencies();
this.setupEventListeners();
}

async fetchSupportedCurrencies() {
try {
const response = await fetch(`${API_BASE_URL}${DEFAULT_CURRENCY}`);
const data = await response.json();
this.currencies = Object.keys(data.rates);
} catch (error) {
console.error('Error fetching supported currencies:', error);
this.currencies = FALLBACK_CURRENCIES;
}
}
async fetchSupportedCurrencies() {
const options = {
cacheDuration: CACHE_EXPIRATION,
onWait: this.showLoadingState.bind(this),
onSuccess: this.handleFetchSuccess.bind(this),
onFailure: this.handleFetchFailure.bind(this),
onComplete: this.hideLoadingState.bind(this),
};

apiClient.request(`${API_BASE_URL}${DEFAULT_CURRENCY}`, options);
}

showLoadingState() {
this.setSelectLoadingState(this.fromCurrencySelect);
this.setSelectLoadingState(this.toCurrencySelect);
}

setSelectLoadingState(selectElement) {
selectElement.innerHTML = '<option value="Loading...">Loading...</option>';
selectElement.disabled = true;
}

handleFetchSuccess(data) {
this.currencies = Object.keys(data.rates);
this.populateCurrencySelects();
}

populateCurrencySelects() {
this.currencies.forEach(currency => {
this.fromCurrencySelect.add(new Option(currency, currency));
this.toCurrencySelect.add(new Option(currency, currency));
});
handleFetchFailure(error) {
console.error("Failure:", error);
this.currencies = FALLBACK_CURRENCIES;
this.populateCurrencySelects();
}

hideLoadingState() {
this.fromCurrencySelect.disabled = false;
this.toCurrencySelect.disabled = false;
this.removeLoadingOption(this.fromCurrencySelect);
this.removeLoadingOption(this.toCurrencySelect);
}

removeLoadingOption(selectElement) {
const loadingOption = selectElement.querySelector('option[value="Loading..."]');
if (loadingOption) {
selectElement.removeChild(loadingOption);
}
}

populateCurrencySelects() {
this.currencies.forEach(currency => {
this.fromCurrencySelect.add(new Option(currency, currency));
this.toCurrencySelect.add(new Option(currency, currency));
});
}

setupEventListeners() {
this.convertBtn.addEventListener('click', () => this.convertCurrency());
}

setupEventListeners() {
this.convertBtn.addEventListener('click', () => this.convertCurrency());
async convertCurrency() {
const amount = parseFloat(this.amountInput.value);
const fromCurrency = this.fromCurrencySelect.value;
const toCurrency = this.toCurrencySelect.value;

if (isNaN(amount)) {
this.resultInput.value = 'Invalid amount';
return;
}

async convertCurrency() {
const amount = this.amountInput.value;
const fromCurrency = this.fromCurrencySelect.value;
const toCurrency = this.toCurrencySelect.value;

try {
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
const result = (amount * rate).toFixed(MAX_DECIMAL_PLACES);
this.resultInput.value = `${result} ${toCurrency}`;
} catch (error) {
console.error('Error converting currency:', error);
this.resultInput.value = 'Error converting currency';
}
try {
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
const result = (amount * rate).toFixed(MAX_DECIMAL_PLACES);
this.resultInput.value = `${result} ${toCurrency}`;
} catch (error) {
console.error('Error converting currency:', error);
this.resultInput.value = 'Error converting currency';
}
}

async getExchangeRate(fromCurrency, toCurrency) {
const options = {
onWait: () => {
this.convertBtn.disabled = true;
this.resultInput.value = "Loading..."
},
onComplete: () => { this.convertBtn.disabled = false; },
};

const { success, data, error } = await apiClient.request(`${API_BASE_URL}${fromCurrency}`, options);

async getExchangeRate(fromCurrency, toCurrency) {
const cacheKey = `${fromCurrency}_${toCurrency}`;
const cachedData = this.cache[cacheKey];

if (cachedData && Date.now() - cachedData.timestamp < CACHE_EXPIRATION) {
return cachedData.rate;
}

try {
const response = await fetch(`${API_BASE_URL}${fromCurrency}`);
const data = await response.json();
const rate = data.rates[toCurrency];

this.cache[cacheKey] = {
rate: rate,
timestamp: Date.now()
};

return rate;
} catch (error) {
console.error('Error fetching exchange rates:', error);
throw error;
}
if (success) {
return data.rates[toCurrency];
} else {
throw error;
}
}
}

export default CurrencyConverter;
1 change: 1 addition & 0 deletions Basic Projects/6-currency_converter/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Currency Converter</title>
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>

<body>
Expand Down
Loading

0 comments on commit 6d4fd34

Please sign in to comment.