/**
* HTTP client for Warframe Market API v2
*/
import { API_BASE_URL, DEFAULT_TIMEOUT, PLATFORMS } from '../constants.js';
import { normalizeLanguage } from './i18n.js';
/**
* Make HTTP request with timeout
* @param {string} url - URL to fetch
* @param {Object} options - Fetch options
* @param {number} timeout - Timeout in milliseconds
* @returns {Promise<Response>}
*/
async function fetchWithTimeout(url, options = {}, timeout = DEFAULT_TIMEOUT) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
}
}
/**
* HTTP Client for Warframe Market API v2
*/
export default class HttpClient {
#baseURL;
#timeout;
#locale;
#logger;
/**
* @param {Object} options - Client options
* @param {string} [options.baseURL] - Base URL for API
* @param {number} [options.timeout] - Request timeout in ms
* @param {string} [options.locale] - Default language
* @param {Object} [options.logger] - Logger instance
*/
constructor(options = {}) {
// Ensure baseURL ends with /
let baseURL = options.baseURL || API_BASE_URL;
if (!baseURL.endsWith('/')) {
baseURL += '/';
}
this.#baseURL = baseURL;
this.#timeout = options.timeout || DEFAULT_TIMEOUT;
this.#locale = normalizeLanguage(options.locale || 'en');
this.#logger = options.logger || console;
}
/**
* Make GET request
* @param {string} path - API path
* @param {Object} options - Request options
* @param {string} [options.platform] - Platform header
* @param {string} [options.locale] - Language header
* @param {Object} [options.query] - Query parameters
* @returns {Promise<Object>}
*/
async get(path, options = {}) {
const url = this.#buildUrl(path, options.query);
const headers = this.#buildHeaders(options);
this.#logger.debug(`[v2] GET ${url}`);
try {
const response = await fetchWithTimeout(url, { headers }, this.#timeout);
if (!response.ok) {
throw await this.#handleError(response);
}
const data = await response.json();
return this.#unwrapResponse(data);
} catch (error) {
this.#logger.error(`HTTP GET ${path} failed:`, error.message);
throw error;
}
}
/**
* Make POST request
* @param {string} path - API path
* @param {Object} body - Request body
* @param {Object} options - Request options
* @returns {Promise<Object>}
*/
async post(path, body, options = {}) {
const url = this.#buildUrl(path);
const headers = {
...this.#buildHeaders(options),
'Content-Type': 'application/json',
};
try {
const response = await fetchWithTimeout(
url,
{
method: 'POST',
headers,
body: JSON.stringify(body),
},
this.#timeout
);
if (!response.ok) {
throw await this.#handleError(response);
}
const data = await response.json();
return this.#unwrapResponse(data);
} catch (error) {
this.#logger.error(`HTTP POST ${path} failed:`, error.message);
throw error;
}
}
/**
* Build full URL with query parameters
* @param {string} path - API path
* @param {Object} [query] - Query parameters
* @returns {string}
* @private
*/
#buildUrl(path, query) {
// Remove leading slash from path if present
const cleanPath = path.startsWith('/') ? path.substring(1) : path;
const url = new URL(cleanPath, this.#baseURL);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, value);
}
});
}
return url.toString();
}
/**
* Build request headers
* @param {Object} options - Options
* @returns {Object}
* @private
*/
#buildHeaders(options = {}) {
const headers = {
Accept: 'application/json',
};
// Add language header
const locale = options.locale || this.#locale;
if (locale) {
headers.Language = locale;
}
// Add platform header if provided
if (options.platform) {
const normalizedPlatform = PLATFORMS[options.platform.toLowerCase()] || options.platform;
headers.Platform = normalizedPlatform;
}
// Add authorization header if provided
if (options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
// Add custom headers
if (options.headers) {
Object.assign(headers, options.headers);
}
return headers;
}
/**
* Handle HTTP error response
* @param {Response} response - Response object
* @returns {Promise<Error>}
* @private
*/
async #handleError(response) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const data = await response.json();
if (data.error && data.error.message) {
errorMessage = data.error.message;
}
} catch {
// If JSON parsing fails, use default error message
}
const error = new Error(errorMessage);
error.status = response.status;
error.statusText = response.statusText;
return error;
}
/**
* Unwrap API response
* @param {Object} data - Response data
* @returns {*}
* @private
*/
#unwrapResponse(data) {
// v2 API wraps responses in { apiVersion, data, error }
if (data.error) {
const error = new Error(data.error.message || 'API Error');
error.code = data.error.code;
throw error;
}
return data.data;
}
/**
* Set default locale
* @param {string} locale - Language code
*/
setLocale(locale) {
this.#locale = normalizeLanguage(locale);
}
/**
* Set timeout
* @param {number} timeout - Timeout in milliseconds
*/
setTimeout(timeout) {
this.#timeout = timeout;
}
}