define([
"md5",
"common/UrlUtilities",
"models/dataONEServices/HttpRetryPolicy",
"models/dataONEServices/DataONEHttpError",
"common/ErrorUtilities",
"common/ValueUtilities",
], (
md5,
UrlUtilities,
HttpRetryPolicy,
DataONEHttpError,
ErrorUtilities,
ValueUtilities,
) => {
const { getCaseInsensitive } = ValueUtilities;
const {
abortableDelay,
createAbortError,
createTimeoutError,
isTimeoutError,
} = ErrorUtilities;
/**
* Header names that affect request deduplication. Defaults.
* @type {string[]}
*/
const DEFAULT_HEADER_NAMES_DEDUP = [
"Content-Type",
"Accept",
"Authorization",
// Note we don't use range or conditional get requests yet, but including
// them here for future-proofing.
"Range",
"If-Match",
"If-Modified-Since",
"If-None-Match",
];
/**
* Default allowed HTTP methods.
* @type {string[]}
*/
const DEFAULT_ALLOWED_HTTP_METHODS = [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"HEAD",
"OPTIONS",
];
/**
* Default supported response types.
* @type {string[]}
*/
const DEFAULT_RESPONSE_TYPES = ["json", "arrayBuffer", "blob", "text"];
/**
* Default options for DataONEHttpClient instances.
* @type {DataONEHttpClientOptions}
*/
const DEFAULT_OPTIONS = {
baseUrl: "",
defaultHeaders: {},
timeoutMs: null,
retry: {},
dedupe: true,
allowedHttpMethods: DEFAULT_ALLOWED_HTTP_METHODS,
headerNamesForDedup: DEFAULT_HEADER_NAMES_DEDUP,
responseTypes: DEFAULT_RESPONSE_TYPES,
};
/**
* Configuration options for {@link DataONEHttpClient}.
* @typedef {object} DataONEHttpClientOptions
* @property {string} [baseUrl] Base URL for relative requests, e.g.
* "https://arcticdata.io/metacat/d1/mn/v2/"
* @property {object} [defaultHeaders] Headers applied to every request
* @property {number|null} [timeoutMs] Default timeout per request (ms). Set
* to null for no timeout.
* @property {object} [retry] Retry configuration, see {@link HttpRetryPolicy}
* @property {boolean} [dedupe] Enable in-flight deduplication. If true, when
* an identical request has already been sent and not yet returned, the
* promise for that request will be returned instead of creating a new one.
* @property {string[]} [allowedHttpMethods] List of allowed HTTP methods
* @property {string[]} [headerNamesForDedup] List of headers to include when
* generating deduplication keys.
* @property {string[]} [responseTypes] List of supported response types.
*/
/**
* Options accepted by {@link DataONEHttpClient#request}.
* @typedef {object} DataONEHttpRequestOptions
* @property {string} path Path relative to `baseUrl` (e.g. "/v2/meta/{pid}")
* @property {"text"|"json"|"arrayBuffer"|"blob"|"document"} [responseType="text"]
* Expected response type, used for parsing.
* @property {boolean} [encodePath=true] Whether to URL-encode path segments.
* @property {string} [method="GET"] HTTP method.
* @property {object} [headers] Per-request headers, to be merged with
* `defaultHeaders`. `headers` must be a plain object (not a `Headers`
* instance).
* @property {*} [body] Request body.
* @property {string|null} [token] Optional auth token (Bearer).
* @property {"follow"|"error"|"manual"} [redirect] Fetch redirect handling.
* @property {AbortSignal} [signal] AbortSignal to cancel the request.
* @property {number|null} [timeoutMs] Override timeout in milliseconds.
* @property {object} [retry] Override retry configuration for this request.
* @property {"fetch"|"xhr"} [transport="fetch"] Transport to use for this
* request.
* @property {boolean} [dedupe] Override request deduplication behavior for
* this request. Defaults to client-level `dedupe`.
* @property {Function} [onUploadProgress] Upload progress callback. Used only
* with the `"xhr"` transport.
*/
/**
* Normalized request options used internally after defaults and normalization
* have been applied.
* @typedef {object} NormalizedDataONEHttpRequestOptions
* @property {string} url Full request URL.
* @property {string} method Normalized HTTP method.
* @property {"text"|"json"|"arrayBuffer"|"blob"|"document"} responseType Normalized
* response type.
* @property {object} headers Effective headers passed to `fetch()` (merged +
* auth).
* @property {*} [body] Request body.
* @property {string|null} [token] Optional auth token (Bearer).
* @property {"follow"|"error"|"manual"} [redirect] Fetch redirect handling.
* @property {AbortSignal} [signal] AbortSignal to cancel the request.
* @property {number|null} timeoutMs Effective timeout in milliseconds.
* @property {object} retry Retry overrides for this request.
* @property {"fetch"|"xhr"} transport Effective transport for this request.
* @property {boolean} dedupe Effective deduplication behavior for this
* request.
* @property {Function|null} onUploadProgress Upload progress callback.
* @property {HttpRetryPolicy} retryPolicy Effective retry policy for this
* request.
*/
/**
* Normalized HTTP response returned by the client.
* @typedef {object} DataONEHttpResponse
* @property {boolean} ok Whether the HTTP status is in the 200-299 range.
* @property {number} status HTTP status code.
* @property {Headers} headers Response headers.
* @property {string} url Final response URL.
* @property {*} data Parsed response data.
*/
/**
* @class DataONEHttpClient
* @classcategory Models/DataONEServices
* @classdesc Generic HTTP client for DataONE APIs. Handles auth headers,
* retries with backoff, optional in-flight deduplication, abort support,
* normalized responses/errors, and timeouts. It does not know about app
* models or TTLs; it only handles transport concerns.
* @since 2.39.0
*/
class DataONEHttpClient {
/**
* @param {DataONEHttpClientOptions} [options] Client configuration
*/
constructor(options = {}) {
const {
baseUrl,
defaultHeaders,
timeoutMs,
retry,
dedupe,
allowedHttpMethods,
headerNamesForDedup,
responseTypes,
} = this.constructor.normalizeOptions(options);
this.baseUrl = baseUrl;
this.defaultHeaders = defaultHeaders;
this.timeoutMs = timeoutMs;
this.retryPolicy = new HttpRetryPolicy(retry);
this.dedupe = dedupe;
// Map of dedupeKey -> Promise for in-flight requests
this.inFlight = new Map();
// For request deduplication, objects that can't be serialized will be
// assigned unique IDs
this.bodyIds = new WeakMap();
this.bodyIdSeq = 0;
this.allowedHttpMethods = allowedHttpMethods;
this.headerNamesForDedup = headerNamesForDedup;
this.responseTypes = responseTypes;
}
/**
* Normalize options for instance keys and default values.
* @param {DataONEHttpClientOptions} [options] Client configuration
* @returns {object} Normalized options
*/
static normalizeOptions(options = {}) {
const source =
options && typeof options === "object" ? options : DEFAULT_OPTIONS;
const copyObject = (value, fallback = {}) =>
value && typeof value === "object" ? { ...value } : { ...fallback };
const copyArray = (value, fallback = []) =>
Array.isArray(value) ? [...value] : [...fallback];
const toUpper = (item) => String(item).toUpperCase();
const toLower = (item) => String(item).toLowerCase();
const dedupe =
source.dedupe === undefined ? DEFAULT_OPTIONS.dedupe : source.dedupe;
if (typeof dedupe !== "boolean") {
throw new Error("dedupe must be a boolean");
}
const rawTimeoutMs =
source.timeoutMs === undefined
? DEFAULT_OPTIONS.timeoutMs
: source.timeoutMs;
const timeoutMs = this.normalizeTimeoutMs(rawTimeoutMs);
return {
...DEFAULT_OPTIONS,
...source,
baseUrl: source.baseUrl
? UrlUtilities.normalizeUrl(source.baseUrl)
: DEFAULT_OPTIONS.baseUrl,
timeoutMs,
dedupe,
// For arrays and objects, we want to ensure we create new instances so
// that they can be safely mutated without affecting the defaults or
// other instances that use the same defaults.
defaultHeaders: copyObject(
source.defaultHeaders,
DEFAULT_OPTIONS.defaultHeaders,
),
retry: copyObject(source.retry, DEFAULT_OPTIONS.retry),
allowedHttpMethods: copyArray(
source.allowedHttpMethods,
DEFAULT_OPTIONS.allowedHttpMethods,
).map(toUpper),
headerNamesForDedup: copyArray(
source.headerNamesForDedup,
DEFAULT_OPTIONS.headerNamesForDedup,
).map(toLower),
responseTypes: copyArray(
source.responseTypes,
DEFAULT_OPTIONS.responseTypes,
).map(toLower),
};
}
/**
* Normalize a timeout value to either a valid number or null.
* @param {number|null|undefined} timeoutMs Timeout in milliseconds
* @returns {number|null} Normalized timeout
* @throws {Error} If timeout is not null/undefined and not a non-negative
* number
* @private
*/
static normalizeTimeoutMs(timeoutMs) {
if (timeoutMs === null || timeoutMs === undefined) return null;
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new Error("timeoutMs must be a non-negative number or null");
}
return timeoutMs;
}
/**
* Build a unique instance key based on client options.
* @param {DataONEHttpClientOptions} [options] Client configuration
* @returns {string} Instance key
*/
static buildInstanceKey(options = {}) {
const normalizedOptions = this.normalizeOptions(options);
const keyFields = [
"baseUrl",
"timeoutMs",
"dedupe",
"defaultHeaders",
"retry",
"allowedHttpMethods",
"headerNamesForDedup",
"responseTypes",
];
const normalizers = {
baseUrl: UrlUtilities.normalizeUrl,
defaultHeaders: ValueUtilities.stableStringify,
retry: ValueUtilities.stableStringify,
allowedHttpMethods: ValueUtilities.stableStringify,
headerNamesForDedup: ValueUtilities.stableStringify,
responseTypes: ValueUtilities.stableStringify,
};
return ValueUtilities.buildInstanceKey(
normalizedOptions,
keyFields,
normalizers,
);
}
/**
* Get a singleton DataONEHttpClient instance for the provided options.
* @param {DataONEHttpClientOptions} [options] Client configuration
* @returns {DataONEHttpClient} Client instance
*/
static get(options = {}) {
return ValueUtilities.getSingleton(
this,
options,
this.buildInstanceKey.bind(this),
);
}
/**
* Normalize and merge request options with client defaults. Also check for
* required options.
* @param {DataONEHttpRequestOptions} options Raw request options
* @returns {NormalizedDataONEHttpRequestOptions} Normalized request options
* @private
*/
normalizeRequestOptions(options = {}) {
const {
path,
method = "GET",
responseType = "text",
encodePath = true,
headers,
body,
token = null,
redirect = "follow",
signal,
timeoutMs: requestTimeoutMs,
retry: retryOverrides = {},
transport = "fetch",
dedupe: requestDedupe,
onUploadProgress = null,
} = options;
if (typeof path !== "string") {
throw new Error("A request path is required");
}
const normalizedMethod = this.normalizeMethod(method);
const normalizedTransport =
this.constructor.normalizeTransport(transport);
const normalizedResponseType = this.normalizeResponseType(
responseType,
normalizedTransport,
);
const url = UrlUtilities.buildUrl(this.baseUrl, path, { encodePath });
const mergedHeaders = this.constructor.mergeHeaders(
this.defaultHeaders,
headers,
);
if (token && !getCaseInsensitive(mergedHeaders, "Authorization")) {
mergedHeaders.Authorization = `Bearer ${token}`;
}
const hasRequestTimeoutOverride =
requestTimeoutMs !== undefined && requestTimeoutMs !== null;
const timeoutSource = hasRequestTimeoutOverride
? requestTimeoutMs
: this.timeoutMs;
const normalizedTimeoutMs =
this.constructor.normalizeTimeoutMs(timeoutSource);
const retry =
retryOverrides && typeof retryOverrides === "object"
? retryOverrides
: {};
const retryPolicy = this.retryPolicy.withOverrides(retry);
const dedupe =
requestDedupe === undefined ? this.dedupe : requestDedupe === true;
if (requestDedupe !== undefined && typeof requestDedupe !== "boolean") {
throw new Error("dedupe must be a boolean");
}
let normalizedOnUploadProgress = null;
if (onUploadProgress !== null && onUploadProgress !== undefined) {
if (typeof onUploadProgress !== "function") {
throw new Error("onUploadProgress must be a function");
}
if (normalizedTransport !== "xhr") {
throw new Error(
'onUploadProgress is only supported when transport is "xhr"',
);
}
normalizedOnUploadProgress = onUploadProgress;
}
return {
url,
method: normalizedMethod,
responseType: normalizedResponseType,
headers: mergedHeaders,
body,
token,
redirect,
signal,
timeoutMs: normalizedTimeoutMs,
retry,
transport: normalizedTransport,
dedupe,
onUploadProgress: normalizedOnUploadProgress,
retryPolicy,
};
}
/**
* Normalize a responseType option to a supported value.
* @param {string} responseType Raw response type
* @param {"fetch"|"xhr"} [transport] Transport value
* @returns {"json"|"arrayBuffer"|"blob"|"text"|"document"} Normalized response type
* @private
*/
normalizeResponseType(responseType, transport = "fetch") {
if (typeof responseType !== "string") {
throw new Error("responseType must be a string");
}
const normalizedResponseType = responseType.toLowerCase();
const normalizedTransport =
this.constructor.normalizeTransport(transport);
const transportSupportedResponseTypes =
normalizedTransport === "xhr"
? ["json", "arraybuffer", "blob", "text", "document"]
: ["json", "arraybuffer", "blob", "text"];
const allowedResponseTypes = transportSupportedResponseTypes.filter(
(type) =>
this.responseTypes.includes(type) ||
(normalizedTransport === "xhr" && type === "document"),
);
if (allowedResponseTypes.includes(normalizedResponseType))
return normalizedResponseType;
// Otherwise throw an error
throw new Error(
`Invalid responseType: ${responseType}. ` +
`Allowed types: ${allowedResponseTypes.join(", ")}`,
);
}
/**
* Merge two header objects case-insensitively. Headers in requestHeaders
* override defaults regardless of key casing.
* @param {object} defaultHeaders Default headers
* @param {object} requestHeaders Request-specific headers
* @returns {object} Merged headers object
* @private
*/
static mergeHeaders(defaultHeaders = {}, requestHeaders = {}) {
const merged = {};
const keyByLower = Object.create(null);
const addHeaders = (headers) => {
if (!headers || typeof headers !== "object") return;
Object.entries(headers).forEach(([key, value]) => {
const normalizedKey = String(key).toLowerCase();
const existingKey = keyByLower[normalizedKey];
if (existingKey && existingKey !== key) {
delete merged[existingKey];
}
keyByLower[normalizedKey] = key;
merged[key] = value;
});
};
addHeaders(defaultHeaders);
addHeaders(requestHeaders);
return merged;
}
/**
* Normalize an HTTP method to an allowed uppercase value.
* @param {string} method Raw HTTP method
* @returns {string} Normalized HTTP method
* @throws {Error} If method is not provided or invalid
* @private
*/
normalizeMethod(method) {
if (typeof method !== "string") {
throw new Error("method must be provided");
}
const normalizedMethod = method.toUpperCase();
if (this.allowedHttpMethods.includes(normalizedMethod)) {
return normalizedMethod;
}
throw new Error(
`Invalid HTTP method: ${method}. ` +
`Allowed methods: ${this.allowedHttpMethods.join(", ")}`,
);
}
/**
* Normalize a transport option to a supported value.
* @param {string} transport Transport value.
* @returns {"fetch"|"xhr"} Normalized transport.
* @throws {Error} When transport is not `fetch` or `xhr`
* @private
* @since 2.39.0
*/
static normalizeTransport(transport) {
if (typeof transport !== "string") {
throw new Error("transport must be a string");
}
const normalizedTransport = transport.toLowerCase();
if (normalizedTransport === "fetch" || normalizedTransport === "xhr") {
return normalizedTransport;
}
throw new Error(
`Invalid transport: ${transport}. Allowed transports: fetch, xhr`,
);
}
/**
* Perform an HTTP request.
* @param {DataONEHttpRequestOptions} options Request options
* @returns {Promise<DataONEHttpResponse>} A promise that resolves to a
* normalized response object.
*/
async request(options = {}) {
const requestOptions = this.normalizeRequestOptions(options);
const useDedupe = requestOptions.dedupe === true;
let dedupKey = null;
if (useDedupe) {
dedupKey = this.generateRequestKey(requestOptions);
const inFlight = this.checkForInFlightRequest(dedupKey);
if (inFlight) return inFlight;
}
const job = this.executeRequest(requestOptions);
if (useDedupe) {
this.addToInFlight(dedupKey, job);
}
try {
return await job;
} finally {
this.removeFromInFlight(dedupKey);
}
}
/**
* Return an in-flight request promise for a dedupe key, if present.
* @param {string|null} dedupKey Dedupe key for the request
* @returns {Promise<DataONEHttpResponse>|null} In-flight promise or null
* @private
*/
checkForInFlightRequest(dedupKey) {
if (!dedupKey || this.inFlight.size === 0) return null;
return this.inFlight.get(dedupKey) || null;
}
/**
* Store an in-flight request promise for deduplication.
* @param {string|null} dedupKey Dedupe key for the request
* @param {Promise<DataONEHttpResponse>} promise Request promise
* @private
*/
addToInFlight(dedupKey, promise) {
if (!dedupKey || !promise) return;
this.inFlight.set(dedupKey, promise);
}
/**
* Remove a request from the in-flight map.
* @param {string|null} dedupKey Dedupe key for the request
* @private
*/
removeFromInFlight(dedupKey) {
if (!dedupKey) return;
if (this.inFlight.size === 0) return;
this.inFlight.delete(dedupKey);
}
/**
* Fetch with retry and timeouts. Do not call directly; use
* {@link DataONEHttpClient#request} instead.
* @param {NormalizedDataONEHttpRequestOptions} options Request options
* @returns {Promise<DataONEHttpResponse>} A promise that resolves to a
* normalized response object.
* @private
*/
async executeRequest(options = {}) {
const { url, retryPolicy, signal } = options;
const { maxAttempts } = retryPolicy;
let attempt = 0;
let lastError;
/* eslint-disable no-await-in-loop */
while (attempt < maxAttempts) {
attempt += 1;
try {
const response = await this.performRequest(options);
if (response.ok) return response;
// Convert non-OK HTTP into a normalized error path
throw new DataONEHttpError({
response,
attempt,
url,
status: response.status,
});
} catch (err) {
// Stop all processing and throw immediately if the request was
// aborted by the caller
if (signal?.aborted && !isTimeoutError(err)) {
throw createAbortError(signal.reason);
}
const normalized =
err instanceof DataONEHttpError
? err
: new DataONEHttpError({
error: err,
url,
attempt,
networkError: isTimeoutError(err) || undefined,
});
lastError = normalized;
const shouldRetry = retryPolicy.shouldRetry({
attempt,
maxAttempts,
status: normalized.status,
isNetworkError: normalized.networkError === true,
});
if (!shouldRetry) throw normalized;
const retryAfterMs = retryPolicy.parseRetryAfter(normalized.headers);
const delay = retryPolicy.computeDelay({ attempt, retryAfterMs });
await abortableDelay(delay, signal);
}
}
/* eslint-enable no-await-in-loop */
throw lastError;
}
/**
* Performs a request using the configured transport.
* @param {NormalizedDataONEHttpRequestOptions} options Request options
* @returns {Promise<DataONEHttpResponse>} A promise that resolves to a
* normalized response object.
* @private
* @since 2.39.0
*/
async performRequest(options = {}) {
if (options.transport === "xhr") {
return this.performXhr(options);
}
return this.performFetch(options);
}
/**
* Performs the actual fetch call. Handles timeouts and aborts. Do not call
* directly; use {@link DataONEHttpClient#request} instead.
* @param {NormalizedDataONEHttpRequestOptions} options Request options
* @returns {Promise<DataONEHttpResponse>} A promise that resolves to a
* normalized response object.
* @private
*/
async performFetch(options = {}) {
const {
url,
method,
headers,
body,
responseType,
redirect,
signal,
timeoutMs,
} = options;
// Enable fetch to be aborted via AbortController. We track whether an
// abort came from the caller's signal vs an internal timeout, so we can
// reliably convert timeouts into TimeoutError.
const controller = new AbortController();
let abortedByCaller = false;
const onAbort = () => {
abortedByCaller = true;
controller.abort();
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
// Abort the fetch if it exceeds the timeout
let timeoutId = null;
let timedOut = false;
let timeoutErr = null;
const effectiveTimeout =
timeoutMs !== undefined ? timeoutMs : this.timeoutMs;
if (effectiveTimeout && effectiveTimeout > 0) {
timeoutErr = createTimeoutError();
timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, effectiveTimeout);
}
try {
const res = await fetch(url, {
method,
headers,
body,
redirect,
signal: controller.signal,
});
const data = await this.constructor.readBody(res, responseType);
return {
ok: res.ok,
status: res.status,
headers: res.headers,
url: res.url,
data,
};
} catch (error) {
// Guarantee that internal timeouts surface as TimeoutError.
if (timedOut) {
timeoutErr.cause = error;
throw timeoutErr;
}
// If the caller aborted, preserve abort semantics.
if (abortedByCaller) {
throw createAbortError(signal?.reason);
}
throw error;
} finally {
if (timeoutId) clearTimeout(timeoutId);
signal?.removeEventListener("abort", onAbort);
}
}
/**
* Performs the actual XMLHttpRequest call. Handles timeouts and aborts. Do
* not call directly; use {@link DataONEHttpClient#request} instead.
* @param {NormalizedDataONEHttpRequestOptions} options Request options
* @returns {Promise<DataONEHttpResponse>} A promise that resolves to a
* normalized response object.
* @private
* @since 2.39.0
*/
async performXhr(options = {}) {
const {
url,
method,
headers,
body,
responseType,
signal,
timeoutMs,
onUploadProgress,
} = options;
const effectiveTimeout =
timeoutMs !== undefined ? timeoutMs : this.timeoutMs;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let completed = false;
let abortedByCaller = false;
const onAbort = () => {
abortedByCaller = true;
xhr.abort();
};
const onProgress =
typeof onUploadProgress === "function"
? (event) => {
try {
onUploadProgress(event);
} catch {
// Ignore upload progress callback errors.
}
}
: null;
const finalize = (callback, value) => {
if (completed) return;
completed = true;
signal?.removeEventListener("abort", onAbort);
if (onUploadProgress && xhr.upload && onProgress) {
xhr.upload.removeEventListener("progress", onProgress);
}
callback(value);
};
if (signal?.aborted) {
finalize(reject, createAbortError(signal.reason));
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
if (onProgress && xhr.upload) {
xhr.upload.addEventListener("progress", onProgress);
}
xhr.open(method, url, true);
if (effectiveTimeout && effectiveTimeout > 0) {
xhr.timeout = effectiveTimeout;
}
if (
responseType !== "text" &&
responseType !== "json" &&
responseType !== ""
) {
xhr.responseType = responseType;
}
Object.entries(headers || {}).forEach(([key, value]) => {
if (value === undefined || value === null) return;
if (Array.isArray(value)) {
value.forEach((item) => {
if (item === undefined || item === null) return;
xhr.setRequestHeader(key, String(item));
});
return;
}
xhr.setRequestHeader(key, String(value));
});
xhr.onload = () => {
try {
const status = Number(xhr.status) || 0;
finalize(resolve, {
ok: status >= 200 && status < 300,
status,
headers: this.constructor.parseRawHeaders(
xhr.getAllResponseHeaders(),
),
url: xhr.responseURL || url,
data: this.constructor.readXhrBody(xhr, responseType),
});
} catch (error) {
finalize(reject, error);
}
};
xhr.onerror = () => {
finalize(reject, new TypeError("Network request failed"));
};
xhr.ontimeout = () => {
finalize(reject, createTimeoutError());
};
xhr.onabort = () => {
if (abortedByCaller || signal?.aborted) {
finalize(reject, createAbortError(signal?.reason));
return;
}
finalize(reject, createAbortError());
};
try {
xhr.send(body === undefined ? null : body);
} catch (error) {
finalize(reject, error);
}
});
}
/**
* Generate a key to differentiate requests for deduplication. The strategy
* veers towards less deduplication in favour of ensureing we don't
* incorrectly dedupe requests that are actually different. For example, a
* request that sets the token via headers will be considered different from
* one that sets it via the token option, for simplicity.
* @param {NormalizedDataONEHttpRequestOptions} options Request options
* @returns {string} A hash key representing the request
* @private
*/
generateRequestKey({
token,
url,
method,
body,
headers,
responseType,
timeoutMs,
retry,
signal,
transport,
redirect,
}) {
// Generate scope based on token or Authorization header
let scope = token ? `auth:${md5(String(token))}` : null;
if (!scope) {
const authHeader = getCaseInsensitive(headers, "Authorization");
if (authHeader) {
scope = `auth:${md5(authHeader)}`;
} else {
scope = "public";
}
}
const bodyNormalized = this.normalizeBodyForKey(body);
const headerRepresentation = this.normalizeHeadersForKey(headers);
const timeoutKey =
timeoutMs === null || timeoutMs === undefined
? "none"
: String(timeoutMs);
const retryKey = ValueUtilities.stableStringify(retry);
const signalKey = signal ? this.getBodyId(signal) : "none";
const keyParts = [
scope,
String(method).toUpperCase(),
url,
transport,
redirect,
responseType,
headerRepresentation,
`timeout:${timeoutKey}`,
`retry:${retryKey}`,
`signal:${signalKey}`,
bodyNormalized,
];
return md5(keyParts.join("|"));
}
/**
* Read a response body according to the requested response type.
* @param {Response} response Fetch response
* @param {"text"|"json"|"arrayBuffer"|"blob"} responseType Expected body
* type
* @returns {Promise<*>} Parsed response data
* @private
*/
static async readBody(response, responseType) {
switch (responseType.toLocaleLowerCase()) {
case "json": {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return text;
}
}
case "arraybuffer":
return response.arrayBuffer();
case "blob":
return response.blob();
case "text":
default:
return response.text();
}
}
/**
* Read an XMLHttpRequest response body according to responseType.
* @param {XMLHttpRequest} xhr XMLHttpRequest instance
* @param {"text"|"json"|"arrayBuffer"|"blob"|"document"} responseType Expected body
* type
* @returns {*} Parsed response data
* @throws {Error} When a text response cannot be read
* @private
* @since 2.39.0
*/
static readXhrBody(xhr, responseType) {
const type = String(responseType || "text").toLowerCase();
const { response } = xhr;
const readResponseText = () => {
try {
return typeof xhr.responseText === "string" ? xhr.responseText : "";
} catch {
return "";
}
};
try {
switch (type) {
case "arraybuffer":
if (response instanceof ArrayBuffer) return response;
if (ArrayBuffer?.isView?.(response)) return response.buffer;
return readResponseText();
case "blob":
if (response instanceof Blob) return response;
if (typeof Blob !== "undefined") {
return new Blob([readResponseText()], { type: "text/plain" });
}
return readResponseText();
case "document":
if (response !== null && response !== undefined) return response;
return readResponseText();
case "json":
// Keep JSON parsing tolerant for environments where responseType is
// not set and only responseText is populated.
if (
response !== null &&
response !== undefined &&
typeof response !== "string"
) {
return response;
}
{
const jsonText =
typeof response === "string" ? response : readResponseText();
return jsonText ? JSON.parse(jsonText) : null;
}
case "text":
default:
if (typeof response === "string") return response;
return readResponseText();
}
} catch (e) {
if (type !== "text") {
return readResponseText();
}
throw e;
}
}
/**
* Parse raw response headers from XMLHttpRequest into a Headers instance.
* @param {string} rawHeaders Raw header string
* @returns {Headers} Parsed headers
* @private
* @since 2.39.0
*/
static parseRawHeaders(rawHeaders = "") {
const headers = new Headers();
if (typeof rawHeaders !== "string" || !rawHeaders.trim()) {
return headers;
}
rawHeaders
.trim()
.split(/[\r\n]+/)
.forEach((line) => {
const separatorIndex = line.indexOf(":");
if (separatorIndex <= 0) return;
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim();
if (!key) return;
headers.append(key, value);
});
return headers;
}
/**
* Convert a body sent with a request into a string suitable for generating
* a deduplication key. It will be assigned an ID if it's a non-serializable
* type. If the same object is used in multiple requests, it will have the
* same ID.
* @param {*} body Request body
* @returns {string} Normalized body representation
* @private
*/
normalizeBodyForKey(body) {
if (body === null || body === undefined) return "";
if (typeof body === "string") return `str:${md5(body)}`;
if (body instanceof URLSearchParams) {
// Stable string representation
return `usp:${body.toString()}`;
}
// Non-serializable / potentially large bodies: use identity
if (body instanceof FormData) {
return `fd:${this.getBodyId(body)}`;
}
let prefix = "";
if (body instanceof Blob || body instanceof File) {
const type = body.type || "unknown";
const size = typeof body.size === "number" ? body.size : 0;
prefix = `blob:${type}:${size}`;
}
// Binary views / buffers: avoid hashing bytes
if (ArrayBuffer?.isView?.(body) || body instanceof ArrayBuffer) {
const byteLength =
typeof body.byteLength === "number" ? body.byteLength : 0;
prefix = `bin:${byteLength}`;
}
if (prefix) {
return `${prefix}:${this.getBodyId(body)}`;
}
// Try to JSON serialize other body types with stable key ordering.
try {
const json = ValueUtilities.stableStringify(body, {
ignoreCase: false,
orderMatters: true,
});
return `json:${md5(json)}`;
} catch (e) {
return `obj:${this.getBodyId(body)}`;
}
}
/**
* Return a stable ID for non-serializable bodies.
* @param {*} value Request body value
* @returns {string} Stable object ID
* @private
*/
getBodyId(value) {
if (!this.bodyIds.has(value)) {
this.bodyIds.set(value, `obj:${this.bodyIdSeq + 1}`);
this.bodyIdSeq += 1;
}
return this.bodyIds.get(value);
}
/**
* Generate a string representation of headers suitable for generating a
* deduplication key. Avoid too much deduplication by only including certain
* headers.
* @param {object} headers Request headers
* @returns {string} Normalized headers representation
* @private
*/
normalizeHeadersForKey(headers) {
if (!headers || typeof headers !== "object") return "";
const parts = this.headerNamesForDedup.map((name) => {
let value = getCaseInsensitive(headers, name) || "";
if (Array.isArray(value)) value = value.join(",");
else if (typeof value === "object") {
try {
value = ValueUtilities.stableStringify(value);
} catch (e) {
value = this.getBodyId(value);
}
}
value = String(value).trim();
if (!value) return "";
return `${name.toLowerCase()}:${value}`;
});
return parts.join("|");
}
}
DataONEHttpClient.DEFAULT_RETRY = HttpRetryPolicy.DEFAULT_RETRY;
DataONEHttpClient.DEFAULT_RETRY_ON = HttpRetryPolicy.DEFAULT_RETRY_ON;
DataONEHttpClient.DEFAULT_HEADER_NAMES_DEDUP = DEFAULT_HEADER_NAMES_DEDUP;
DataONEHttpClient.ALLOWED_HTTP_METHODS = DEFAULT_ALLOWED_HTTP_METHODS;
DataONEHttpClient.RESPONSE_TYPES = DEFAULT_RESPONSE_TYPES;
DataONEHttpClient.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
DataONEHttpClient.Error = DataONEHttpError;
DataONEHttpClient.instances = new Map();
return DataONEHttpClient;
});