@itwin/itwins-client
Version:
iTwins client for the iTwin platform
427 lines • 16.8 kB
JavaScript
/**
* Type guard to validate if an object is a valid Error structure
* @param error - Unknown object to validate
* @returns True if the object is a valid Error type
*/
function isValidError(error) {
if (typeof error !== "object" || error === null) {
return false;
}
const obj = error;
return typeof obj.code === "string" && typeof obj.message === "string";
}
/**
* Type guard to validate if response data contains an error
* @param data - Unknown response data to validate
* @returns True if the data contains a valid Error object
*/
function isErrorResponse(data) {
if (typeof data !== "object" || data === null) {
return false;
}
const obj = data;
return "error" in obj && isValidError(obj.error);
}
/**
* Base client class providing common functionality for iTwins API requests.
* Handles authentication, request configuration, and query string building, and error validation.
*/
export class BaseBentleyAPIClient {
/**
* The max redirects for iTwins API endpoints.
* The max redirects can be customized via the constructor parameter or automatically
* modified based on the IMJS_MAX_REDIRECTS environment variable.
*
* @readonly
*/
_maxRedirects = 5;
/**
* Creates a new BaseClient instance for API operations
* @param maxRedirects - Optional custom max redirects, defaults to 5
*
* @example
* ```typescript
* // Use default max redirects
* const client = new BaseClient();
*
* // Use custom max redirects
* const client = new BaseClient(10);
* ```
*/
constructor(maxRedirects) {
if (maxRedirects !== undefined) {
this._maxRedirects = maxRedirects;
}
else {
this._maxRedirects = globalThis.IMJS_MAX_REDIRECTS ?? 5;
}
}
/**
* Sends a generic API request with type safety and response validation.
* Handles authentication, error responses, and data extraction automatically.
* Error responses follow APIM standards for consistent error handling.
*
* @param accessToken - The client access token for authentication
* @param method - The HTTP method type (GET, POST, DELETE, etc.)
* @param url - The complete URL of the request endpoint
* @param data - Optional payload data for the request body
* @param headers - Optional additional request headers
* @returns Promise that resolves to the parsed API response with type safety
*/
async sendGenericAPIRequest(accessToken, method, url, data, headers, allowRedirects = false) {
try {
const requestOptions = this.createRequestOptions(accessToken, method, url, data, headers);
const response = await fetch(requestOptions.url, {
method: requestOptions.method,
headers: requestOptions.headers,
body: requestOptions.body,
redirect: 'manual',
});
// Browser fetch returns an opaque redirect when redirect is set to manual
if (response.type === "opaqueredirect") {
if (!allowRedirects) {
return {
status: 403,
error: {
code: "RedirectsNotAllowed",
message: "Redirects are not allowed for this request.",
},
};
}
return await this.followRedirectWithFetchFollow(requestOptions);
}
// Handle 302 redirects with auth header forwarding
if (response.status === 302) {
if (!allowRedirects) {
return {
status: 403,
error: {
code: "RedirectsNotAllowed",
message: "Redirects are not allowed for this request.",
},
};
}
return await this.followRedirect(response, accessToken, method, data, headers);
}
// Process non-redirect response
return await this.processResponse(response);
}
catch {
return this.createInternalServerError();
}
}
/**
* Follows redirects using the fetch default 'follow' behavior.
* Used for environments where manual redirect returns opaque responses.
*
* @param requestOptions - The original request options
* @returns Promise that resolves to the final API response
*/
async followRedirectWithFetchFollow(requestOptions) {
try {
const response = await fetch(requestOptions.url, {
method: requestOptions.method,
headers: requestOptions.headers,
body: requestOptions.body,
redirect: "follow",
});
if (response.redirected) {
try {
this.validateRedirectUrlSecurity(response.url);
}
catch (error) {
return {
status: 502,
error: {
code: "InvalidRedirectUrl",
message: error instanceof Error ? error.message : "Invalid redirect URL",
},
};
}
}
return await this.processResponse(response);
}
catch {
return this.createInternalServerError();
}
}
/**
* Handles 302 redirect responses by validating and following the redirect.
*
* @param response - The 302 redirect response
* @param accessToken - The client access token
* @param method - The HTTP method
* @param data - Optional request payload
* @param headers - Optional request headers (will be forwarded to redirect)
* @param redirectCount - Current redirect depth
* @returns Promise that resolves to the final API response
*/
async followRedirect(response, accessToken, method, data, headers, redirectCount = 0) {
// Verify redirect is valid and safe to follow
const verificationResult = this.checkRedirectValidity(response, redirectCount);
if (verificationResult.error) {
return verificationResult.error;
}
const redirectUrl = verificationResult.redirectUrl;
try {
const requestOptions = this.createRequestOptions(accessToken, method, redirectUrl, data, headers);
const redirectResponse = await fetch(requestOptions.url, {
method: requestOptions.method,
headers: requestOptions.headers,
body: requestOptions.body,
redirect: 'manual',
});
// Handle subsequent 302 redirects
if (redirectResponse.status === 302) {
return await this.followRedirect(redirectResponse, accessToken, method, data, headers, redirectCount + 1);
}
// Process final response
return await this.processResponse(redirectResponse);
}
catch {
return this.createInternalServerError();
}
}
/**
* Processes a non-redirect HTTP response.
*
* @param response - The HTTP response to process
* @returns Promise that resolves to a typed API response
*/
async processResponse(response) {
const responseData = response.status !== 204 ? await response.json() : undefined;
if (!response.ok) {
if (isErrorResponse(responseData)) {
return {
status: response.status,
error: responseData.error,
};
}
throw new Error("An error occurred while processing the request");
}
return {
status: response.status,
data: responseData === undefined || responseData === ""
? undefined
: responseData,
};
}
/**
* Creates a generic internal server error response.
*
* @returns A 500 error response for internal exceptions
*/
createInternalServerError() {
return {
status: 500,
error: {
code: "InternalServerError",
message: "An internal exception happened while calling iTwins Service",
},
};
}
/**
* Verifies that a redirect response is valid and safe to follow.
* Performs three critical validations:
* 1. Checks redirect count to prevent infinite loops
* 2. Ensures Location header is present
* 3. Validates redirect URL for security
*
* @param response - The 302 redirect response to verify
* @param redirectCount - Current redirect depth
* @returns Verification result with either error or validated redirect URL
*/
checkRedirectValidity(response, redirectCount) {
// Check redirect limit to prevent infinite loops
if (redirectCount >= this._maxRedirects) {
return {
error: {
status: 508,
error: {
code: "TooManyRedirects",
message: `Maximum redirect limit (${this._maxRedirects}) exceeded. Possible redirect loop detected.`,
},
},
redirectUrl: "",
};
}
// Extract and validate redirect URL
const redirectUrl = response.headers.get('location');
if (!redirectUrl) {
return {
error: {
status: 502,
error: {
code: "InvalidRedirect",
message: "302 redirect response missing Location header",
},
},
redirectUrl: "",
};
}
// Validate redirect URL for security
try {
this.validateRedirectUrlSecurity(redirectUrl);
}
catch (error) {
return {
error: {
status: 502,
error: {
code: "InvalidRedirectUrl",
message: error instanceof Error ? error.message : "Invalid redirect URL",
},
},
redirectUrl: "",
};
}
// All validations passed
return { redirectUrl };
}
/**
* Validates that a redirect URL is secure and targets a trusted APIM Bentley domain.
*
* This method enforces security requirements for following HTTP redirects:
* - URL must use HTTPS protocol (not HTTP)
* - Domain must be a Bentley-owned domain (*api.bentley.com)
*
* @param url - The redirect URL to validate
* @returns True if the URL is valid and safe to follow
* @throws Error if the URL is invalid, uses HTTP, or targets an untrusted domain
*
* @remarks
* This validation is critical for security when following 302 redirects in federated
* architecture scenarios. It prevents redirect attacks that could leak authentication
* credentials to malicious domains.
*
* @example
* ```typescript
* // Valid URLs
* this.validateRedirectUrl("https://api.bentley.com/resource");
* // Invalid URLs (will throw)
* this.validateRedirectUrl("https://evil-tuna.com/phishing/"); // Non-Bentley domain
* this.validateRedirectUrl("https://bentley.com.evil.com/fake"); // Domain spoofing attempt
* ```
*/
validateRedirectUrlSecurity(url) {
let parsedUrl;
try {
parsedUrl = new URL(url);
}
catch {
throw new Error(`Invalid redirect URL: malformed URL "${url}"`);
}
// Require HTTPS protocol for security
if (parsedUrl.protocol !== "https:") {
throw new Error(`Invalid redirect URL: HTTPS required, but URL uses "${parsedUrl.protocol}" protocol. URL: ${url}`);
}
// Validate domain is a Bentley-owned domain (specific whitelist)
const hostname = parsedUrl.hostname.toLowerCase();
const allowedDomains = [
"api.bentley.com",
];
const isBentleyDomain = allowedDomains.some(domain => hostname === domain || hostname.endsWith(`-${domain}`));
if (!isBentleyDomain) {
throw new Error(`Invalid redirect URL: domain "${hostname}" is not a trusted Bentley domain. Only api.bentley.com and its subdomains are allowed.`);
}
return true;
}
/**
* Creates request configuration options with authentication headers.
* Validates required parameters and sets up proper content type for JSON requests.
*
* @param accessTokenString - The client access token string for authorization
* @param method - The HTTP method type (GET, POST, DELETE, etc.)
* @param url - The complete URL of the request endpoint
* @param data - Optional payload data to be JSON stringified for the request body
* @param headers - Optional additional request headers to include
* @returns RequestConfig object with method, URL, body, and headers configured
* @throws Will throw an error if access token or URL are missing/invalid
*/
createRequestOptions(accessTokenString, method, url, data, headers = {}) {
if (!accessTokenString) {
throw new Error("Access token is required");
}
if (!url) {
throw new Error("URL is required");
}
let body;
if (!(data instanceof Blob)) {
body = JSON.stringify(data);
}
else {
body = data;
}
return {
method,
url,
body,
headers: {
...headers,
authorization: accessTokenString,
"content-type": headers.contentType || headers["content-type"]
? headers.contentType || headers["content-type"]
: "application/json",
},
};
}
/**
* Builds a query string to be appended to a URL from query arguments
* @param parameterMapping - Parameter mapping configuration that maps object properties to query parameter names
* @param queryArg - Object containing queryable properties for filtering
* @returns Query string with parameters applied, ready to append to a URL
*
* @example
* ```typescript
* const queryString = this.getQueryStringArg(
* ITwinsAccess.ITWINS_QUERY_PARAM_MAPPING,
* {
* search: "Building A",
* top: 10,
* subClass: "Asset"
* }
* );
* // Returns: "$search=Building%20A&$top=10&subClass=Asset"
* ```
*/
getQueryStringArg(parameterMapping, queryArg) {
if (!queryArg)
return "";
const params = this.buildQueryParams(queryArg, parameterMapping);
return params.join("&");
}
/**
* Helper method to build query parameter array from mapping.
* Uses exhaustive parameter mapping to ensure type safety and prevent missing parameters.
* Automatically handles URL encoding and filters out excluded parameters.
*
* @param queryArg - Object containing queryable properties
* @param mapping - Parameter mapping configuration that maps object properties to query parameter names
* @returns Array of formatted query parameter strings ready for URL construction
*
* @example
* ```typescript
* const params = this.buildQueryParams(
* { search: "Building A", top: 10 },
* { search: "$search", top: "$top" }
* );
* // Returns: ["$search=Building%20A", "$top=10"]
* ```
*/
buildQueryParams(queryArg, mapping) {
const params = [];
// Type assertion constrains paramKey to actual property names and mappedValue to the specific strings from the mapping
// Narrows from set of all strings to only valid keys/values
for (const [paramKey, mappedValue] of Object.entries(mapping)) {
if (mappedValue === "")
continue;
const queryArgValue = queryArg[paramKey];
if (queryArgValue !== undefined && queryArgValue !== null) {
const stringValue = String(queryArgValue);
params.push(`${mappedValue}=${encodeURIComponent(stringValue)}`);
}
}
return params;
}
}
//# sourceMappingURL=BaseBentleyAPIClient.js.map