@vechain/sdk-network
Version:
This module serves as the standard interface connecting decentralized applications (dApps) and users to the VeChainThor blockchain
365 lines (331 loc) • 14.4 kB
text/typescript
import { HttpMethod } from './HttpMethod';
import {
InvalidHTTPParams,
InvalidHTTPRequest,
HttpNetworkError
} from '@vechain/sdk-errors';
import { type HttpClient } from './HttpClient';
import { type HttpParams } from './HttpParams';
import { logRequest, logResponse, logError } from './trace-logger';
/**
* This class implements the HttpClient interface using the Fetch API.
*
* The SimpleHttpClient allows making {@link HttpMethod} requests with timeout
* and base URL configuration.
*/
class SimpleHttpClient implements HttpClient {
/**
* Represent the default timeout duration for network requests in milliseconds.
*/
public static readonly DEFAULT_TIMEOUT = 10000;
/**
* Return the root URL for the API endpoints.
*/
public readonly baseURL: string;
public readonly headers: HeadersInit;
/**
* Return the amount of time in milliseconds before a timeout occurs
* when requesting with HTTP methods.
*/
public readonly timeout: number;
/**
* Constructs an instance of SimpleHttpClient with the given base URL,
* timeout period and HTTP headers.
* The HTTP headers are used each time this client send a request to the URL,
* if not overwritten by the {@link HttpParams} of the method sending the request.
*
* @param {string} baseURL - The base URL for HTTP requests.
* @param {HeadersInit} [headers=new Headers()] - The default headers for HTTP requests.
* @param {number} [timeout=SimpleHttpClient.DEFAULT_TIMEOUT] - The timeout duration in milliseconds.
*/
constructor(
baseURL: string,
headers: HeadersInit = new Headers(),
timeout: number = SimpleHttpClient.DEFAULT_TIMEOUT
) {
this.baseURL = baseURL;
this.timeout = timeout;
this.headers = headers;
}
/**
* Sends an HTTP GET request to the specified path with optional query parameters.
*
* @param {string} path - The endpoint path to which the HTTP GET request is sent.
* @param {HttpParams} [params] - Optional parameters for the request,
* including query parameters, headers, body, and response validation.
* {@link HttpParams.headers} override {@link SimpleHttpClient.headers}.
* @return {Promise<unknown>} A promise that resolves with the response of the GET request.
*/
public async get(path: string, params?: HttpParams): Promise<unknown> {
return await this.http(HttpMethod.GET, path, params);
}
/**
* Determines if specified url is valid
* @param {string} url Url to check
* @returns {boolean} if value
*/
private isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Executes an HTTP request with the specified method, path, and optional parameters.
*
* @param {HttpMethod} method - The HTTP method to use for the request (e.g., GET, POST).
* @param {string} path - The URL path for the request. Leading slashes will be automatically removed.
* @param {HttpParams} [params] - Optional parameters for the request,
* including query parameters, headers, body, and response validation.
* {@link HttpParams.headers} override {@link SimpleHttpClient.headers}.
* @return {Promise<unknown>} A promise that resolves to the response of the HTTP request.
* @throws {InvalidHTTPRequest} Throws an error if the HTTP request fails.
*/
public async http(
method: HttpMethod,
path: string,
params?: HttpParams
): Promise<unknown> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.timeout);
let url: URL | undefined;
let requestStartTime = Date.now(); // Initialize with current timestamp
let headerObj: Record<string, string> = {};
try {
// Remove leading slash from path
if (path.startsWith('/')) {
path = path.slice(1);
}
// Add trailing slash from baseURL if not present
let baseURL = this.baseURL;
if (!baseURL.endsWith('/')) {
baseURL += '/';
}
// Check if path is already a fully qualified URL
if (/^https?:\/\//.exec(path)) {
url = new URL(path);
} else {
url = new URL(path, baseURL);
}
if (params?.query && url !== undefined) {
Object.entries(params.query).forEach(([key, value]) => {
(url as URL).searchParams.append(key, String(value));
});
}
if (
params?.query !== undefined &&
params?.query != null &&
url !== undefined
) {
Object.entries(params.query).forEach(([key, value]) => {
(url as URL).searchParams.append(key, String(value));
});
}
const headers = new Headers(this.headers);
if (params?.headers !== undefined && params?.headers != null) {
Object.entries(params.headers).forEach(([key, value]) => {
headers.append(key, String(value));
});
}
// Convert Headers to plain object for logging
headerObj = Object.fromEntries(headers.entries());
// Log the request
requestStartTime = logRequest(
method,
url.toString(),
headerObj,
method !== HttpMethod.GET ? params?.body : undefined
);
// Send request
const response = await fetch(url.toString(), {
method,
headers,
body:
method !== HttpMethod.GET
? params?.rawBody !== undefined
? (params.rawBody as BodyInit)
: JSON.stringify(params?.body)
: undefined,
signal: controller.signal
});
const responseHeaders = Object.fromEntries(
response.headers.entries()
);
if (response.ok) {
if (
params?.validateResponseHeader != null &&
responseHeaders != null
) {
params.validateResponseHeader(responseHeaders);
}
// Parse response body
// Using explicit type annotation to handle the 'any' returned by response.json()
const responseBody: unknown = await response.json();
// Log the successful response
logResponse(
requestStartTime,
url.toString(),
responseHeaders,
responseBody
);
// Return the responseBody as unknown rather than 'any'
return responseBody;
}
// Extract response body for error context
let responseBodyText: string | undefined;
try {
// Clone the response to avoid consuming it
const clonedResponse = response.clone();
responseBodyText = await clonedResponse.text();
} catch {
// If we can't read the response body, continue without it
responseBodyText = undefined;
}
// Create error message with response body if available
let errorMessage = `HTTP ${response.status} ${response.statusText}`;
if (responseBodyText?.trim()) {
const trimmedBody = responseBodyText.trim();
// Skip HTML responses
if (trimmedBody.includes('<!DOCTYPE html>')) {
// Don't include HTML content
}
// Try to parse JSON and extract error code and message
else if (
trimmedBody.startsWith('{') ||
trimmedBody.startsWith('[')
) {
try {
const jsonData = JSON.parse(trimmedBody) as Record<
string,
unknown
>;
if (
jsonData.error &&
typeof jsonData.error === 'object' &&
jsonData.error !== null
) {
const errorObj = jsonData.error as Record<
string,
unknown
>;
const errorCode = (errorObj.code as string) ?? '';
const errorMsg =
(errorObj.message as string) ??
(errorObj.msg as string) ??
'';
// Include error code, message, and data if it's a string
if (errorCode) errorMessage += ` [${errorCode}]`;
if (errorMsg) errorMessage += ` - ${errorMsg}`;
// Include data if it's a string (not an object)
const errorData = errorObj.data;
if (
errorData &&
typeof errorData === 'string' &&
errorData.trim()
) {
errorMessage += ` (${errorData})`;
}
}
} catch {
// If JSON parsing fails, don't include anything
}
}
// For plain text, parse and extract useful information
else if (
// Include plain text error bodies (even if long) as they often contain
// crucial details coming directly from Thor (e.g. "invalid character 'x' ...").
// We only skip HTML payloads.
!trimmedBody.includes('<!DOCTYPE html>')
) {
// Try to extract key information from plain text
const lines = trimmedBody.split('\n');
const firstLine = lines[0].trim();
// Look for common error patterns
if (firstLine.includes(': ')) {
// Format: "field: error message"
const [field, message] = firstLine.split(': ', 2);
if (field && message) {
errorMessage += ` - ${field}: ${message}`;
} else {
errorMessage += ` - ${firstLine}`;
}
} else if (firstLine.includes(' - ')) {
// Format: "error - details"
errorMessage += ` - ${firstLine}`;
} else {
// Just include the first line if it's short
errorMessage += ` - ${firstLine}`;
}
}
}
throw new Error(errorMessage, {
cause: response
});
} catch (error) {
// Different error handling based on whether it's a params error or request error
if (url) {
// Log the error if url is defined (request was started)
const urlString = url.toString();
logError(requestStartTime, urlString, method, error);
// Check if this is a network communication error
// According to Fetch API spec: https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#exceptions
// Network errors throw TypeError, while HTTP errors (4xx/5xx) are handled in the response.ok check
if (error instanceof TypeError) {
throw new HttpNetworkError(
'HttpClient.http()',
error.message,
{
method,
url: urlString,
networkErrorType: 'TypeError'
},
error
);
}
// If not a network error, treat as HTTP protocol error
throw new InvalidHTTPRequest(
'HttpClient.http()',
(error as Error).message,
{
method,
url: urlString
},
error
);
} else {
// Parameter error before request was even started
const fallbackUrl = !this.isValidUrl(this.baseURL)
? path
: new URL(path, this.baseURL).toString();
throw new InvalidHTTPParams(
'HttpClient.http()',
(error as Error).message,
{
method,
url: fallbackUrl
},
error
);
}
} finally {
clearTimeout(timeoutId);
}
}
/**
* Makes an HTTP POST request to the specified path with optional parameters.
*
* @param {string} path - The endpoint to which the POST request is made.
* @param {HttpParams} [params] - Optional parameters for the request,
* including query parameters, headers, body, and response validation.
* {@link HttpParams.headers} override {@link SimpleHttpClient.headers}.
* @return {Promise<unknown>} A promise that resolves with the response from the server.
*/
public async post(path: string, params?: HttpParams): Promise<unknown> {
return await this.http(HttpMethod.POST, path, params);
}
}
export { SimpleHttpClient };