import { config } from "./config.util.js";
import {
classifyApiError,
createApiError,
createAuthInvalidError,
createNetworkError,
createUnexpectedError,
McpError,
} from "./error.util.js";
import { Logger } from "./logger.util.js";
// Create a contextualized logger for this file
const transportLogger = Logger.forContext("utils/transport.util.ts");
// Log transport utility initialization
transportLogger.debug("Transport utility initialized");
/**
* Interface for IP API credentials.
* Note: API token is optional for the free tier.
*/
export interface IpApiCredentials {
apiToken?: string;
}
/**
* Interface for Lokalise API credentials.
* Note: API token is required for all operations.
*/
export interface LokaliseApiCredentials {
apiToken: string;
}
/**
* Interface for HTTP request options
*/
export interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
body?: unknown;
}
/**
* Retrieves IP API credentials from configuration.
* Specifically checks for IPAPI_API_TOKEN.
* @returns IpApiCredentials object containing the API token if found.
*/
export function getIpApiCredentials(): IpApiCredentials {
const methodLogger = Logger.forContext(
"utils/transport.util.ts",
"getIpApiCredentials",
);
const apiToken = config.get("IPAPI_API_TOKEN");
if (!apiToken) {
methodLogger.debug(
"No IP API token found (IPAPI_API_TOKEN). Using free tier.",
);
return {}; // Return empty object if no token
}
methodLogger.debug("Using IP API token from configuration.");
return { apiToken };
}
/**
* Retrieves Lokalise API credentials from configuration.
* Specifically checks for LOKALISE_API_KEY.
* @returns LokaliseApiCredentials object containing the API token.
* @throws {McpError} If no API token is found.
*/
export function getLokaliseApiCredentials(): LokaliseApiCredentials {
const methodLogger = Logger.forContext(
"utils/transport.util.ts",
"getLokaliseApiCredentials",
);
const apiToken = config.get("LOKALISE_API_KEY");
if (!apiToken) {
methodLogger.error("No Lokalise API token found (LOKALISE_API_KEY)");
throw createAuthInvalidError(
"Lokalise API token is required. Please set LOKALISE_API_KEY environment variable.",
);
}
methodLogger.debug("Using Lokalise API token from configuration.");
return { apiToken };
}
/**
* Fetches data specifically from the ip-api.com endpoint.
* Handles URL construction, authentication (if token provided), and query parameters.
* Relies on the generic fetchApi function for the actual HTTP request.
*
* @param path The specific IP address or path component (e.g., "8.8.8.8"). Empty string for current IP.
* @param options Additional options like HTTP method, headers, body, and ip-api specific params.
* @param options.useHttps - Use HTTPS (requires paid plan for ip-api.com). Defaults to false.
* @param options.fields - Specific fields to request from ip-api.com.
* @param options.lang - Language code for response data.
* @returns The response data parsed as type T.
* @throws {McpError} If the request fails, including network errors, API errors, or parsing issues.
*/
export async function fetchIpApi<T>(
path: string,
options: RequestOptions & {
useHttps?: boolean;
fields?: string[];
lang?: string;
} = {},
): Promise<T> {
const methodLogger = Logger.forContext(
"utils/transport.util.ts",
"fetchIpApi",
);
// Get credentials (token might be undefined)
const credentials = getIpApiCredentials();
// Determine protocol based on options
const protocol = options.useHttps ? "https" : "http";
const baseUrl = `${protocol}://ip-api.com/json`;
// Format path for URL
const normalizedPath = path ? `/${path}` : "";
let url = `${baseUrl}${normalizedPath}`;
// Build query parameters
const queryParams = new URLSearchParams();
// Add API token if present
if (credentials.apiToken) {
queryParams.set("key", credentials.apiToken);
methodLogger.debug("API token added to query parameters.");
}
// Add fields parameter
if (options.fields?.length) {
queryParams.set("fields", options.fields.join(","));
methodLogger.debug(`Requesting fields: ${options.fields.join(",")}`);
}
// Add language parameter
if (options.lang) {
queryParams.set("lang", options.lang);
methodLogger.debug(`Requesting language: ${options.lang}`);
}
// Append query string if needed
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;
}
methodLogger.debug(`Constructed URL: ${url}`);
// Delegate the actual fetch call to the generic fetchApi
return fetchApi<T>(url, {
method: options.method,
headers: options.headers,
body: options.body,
});
}
/**
* Fetches data specifically from the Lokalise API endpoint.
* Handles URL construction, authentication, and query parameters.
* Relies on the generic fetchApi function for the actual HTTP request.
*
* @param path The API path (e.g., "/projects", "/languages").
* @param options Additional options like HTTP method, headers, and body.
* @returns The response data parsed as type T.
* @throws {McpError} If the request fails, including network errors, API errors, or parsing issues.
*/
export async function fetchLokaliseApi<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const methodLogger = Logger.forContext(
"utils/transport.util.ts",
"fetchLokaliseApi",
);
// Get credentials (token is required)
const credentials = getLokaliseApiCredentials();
// Get the API hostname from configuration
const apiHostname =
config.get("LOKALISE_API_HOSTNAME") || "https://api.lokalise.com/api2/";
// Ensure the hostname ends with a slash and remove any duplicate slashes
const baseUrl = apiHostname.endsWith("/")
? apiHostname.slice(0, -1)
: apiHostname;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = `${baseUrl}${normalizedPath}`;
methodLogger.debug(`Constructed Lokalise API URL: ${url}`);
// Delegate the actual fetch call to the generic fetchApi with Lokalise headers
return fetchApi<T>(url, {
method: options.method,
headers: {
"X-Api-Token": credentials.apiToken,
...options.headers,
},
body: options.body,
});
}
/**
* Generic and reusable function to fetch data from any API endpoint.
* Handles standard HTTP request setup, response checking, basic error handling, and logging.
*
* @param url The full URL to fetch data from.
* @param options Request options including method, headers, and body.
* @returns The response data parsed as type T.
* @throws {McpError} If the request fails, including network errors, non-OK HTTP status, or JSON parsing issues.
*/
export async function fetchApi<T>(
url: string,
options: RequestOptions = {},
): Promise<T> {
const methodLogger = Logger.forContext("utils/transport.util.ts", "fetchApi");
// Prepare standard request options
const requestOptions: RequestInit = {
method: options.method || "GET",
headers: {
// Standard headers, allow overrides via options.headers
"Content-Type": "application/json",
Accept: "application/json",
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
};
methodLogger.debug(`Executing API call: ${requestOptions.method} ${url}`);
const startTime = performance.now(); // Track performance
try {
const response = await fetch(url, requestOptions);
const endTime = performance.now();
const duration = (endTime - startTime).toFixed(2);
methodLogger.debug(
`API call completed in ${duration}ms with status: ${response.status} ${response.statusText}`,
{
url,
status: response.status,
},
);
// Check if the response status is OK (2xx)
if (!response.ok) {
const errorText = await response.text(); // Get error body for context
methodLogger.error(`API error response (${response.status}):`, errorText);
// Use the new error classification system
throw classifyApiError(
errorText,
response.status,
`API request failed with status ${response.status}: ${response.statusText}`,
);
}
// Attempt to parse the response body as JSON
try {
const responseData = await response.json();
methodLogger.debug("Response body successfully parsed as JSON.");
// methodLogger.debug('Response Data:', responseData); // Uncomment for full response logging
return responseData as T;
} catch (parseError) {
methodLogger.error("Failed to parse API response JSON:", parseError);
// Throw a specific error for JSON parsing failure
throw createApiError(
`Failed to parse API response JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
response.status, // Include original status for context
parseError,
);
}
} catch (error) {
const endTime = performance.now();
const duration = (endTime - startTime).toFixed(2);
methodLogger.error(
`API call failed after ${duration}ms for ${url}:`,
error,
);
// Rethrow if it's already an McpError (e.g., from status checks or parsing)
if (error instanceof McpError) {
throw error;
}
// Handle potential network errors (TypeError in fetch)
if (error instanceof TypeError) {
throw createNetworkError(
`Network error during API call: ${error.message}`,
error,
);
}
// Wrap any other unexpected errors
throw createUnexpectedError(
`Unexpected error during API call: ${error instanceof Error ? error.message : String(error)}`,
error,
);
}
}