UNPKG

cumulocity-cypress

Version:
358 lines (357 loc) 13.2 kB
import _ from "lodash"; import { getAuthOptionsFromBasicAuthHeader, getAuthOptionsFromJWT, } from "./auth"; import { isCypressResponse, isPactRecord, } from "./c8ypact/c8ypact"; import { get_i } from "./util"; import { toUrlString } from "./url"; /** * C8yClientError is an error class used to throw errors related to the c8yclient command. * It extends the built-in Error class and adds an optional originalError property. */ export class C8yClientError extends Error { constructor(message, originalError) { super(message); this.name = "C8yClientError"; this.originalError = originalError; } } export async function wrapFetchRequest(url, fetchOptions, logOptions) { // client.tenant.current() does add content-type header for some reason. probably mistaken accept header. // as this is not required, remove it to avoid special handling in pact matching against recordings // not created by c8y/client. if (_.endsWith(toUrlString(url), "/tenant/currentTenant")) { // @ts-expect-error fetchOptions.headers = _.omit(fetchOptions.headers, ["content-type"]); } else { // add json content type if body is present and content-type is not set const method = fetchOptions?.method || "GET"; if (fetchOptions?.body && method !== "GET" && method != "HEAD" && !get_i(fetchOptions.headers, "content-type")) { fetchOptions.headers = { "content-type": "application/json", ...fetchOptions.headers, }; } } const startTime = logOptions?.startTime || Date.now(); const contextId = logOptions?.contextId; // Notify about request start if (logOptions?.onRequestStart && contextId) { const method = fetchOptions?.method || "GET"; logOptions.onRequestStart({ contextId, url: toUrlString(url), method, headers: fetchOptions?.headers, body: fetchOptions?.body, startTime, }); } const fetchFn = _.get(globalThis, "fetchStub") || globalThis.fetch; const fetchPromise = fetchFn(url, fetchOptions); const options = { url, fetchOptions, logOptions: { ...logOptions, startTime, }, duration: 0, // Will be calculated when response arrives }; return fetchPromise .then(async (response) => { const duration = Date.now() - startTime; options.duration = duration; const res = await wrapFetchResponse(response, options); return Promise.resolve(res); }) .catch(async (error) => { const duration = Date.now() - startTime; // Check if this is a network error (TypeError) rather than an HTTP error response if (_.isError(error)) { if (isC8yClientError(error)) throw error; throwC8yClientError(error, url, { ...logOptions, method: fetchOptions?.method || "GET", }); } // If it's not an Error object, treat it as a response (shouldn't happen in normal cases) const res = await wrapFetchResponse(error, { ...options, duration }); return Promise.reject(res); }); } export async function wrapFetchResponse(response, options = {}) { // only wrap valid responses or new Response() will fail later if (response.status == null || response.status < 200 || response.status > 599) { return response; } const responseObj = await (async () => { return toCypressResponse(response, options.duration, options.fetchOptions, options.url); })(); if (!responseObj) return response; let rawBody = undefined; if (response.data) { responseObj.body = response.data; rawBody = _.isObject(responseObj.body) ? JSON.stringify(responseObj.body) : responseObj.body; } else if (response.body) { try { rawBody = await response.text(); const json = JSON.parse(rawBody); responseObj.body = _.isObjectLike(json) ? json : rawBody; } catch { responseObj.body = rawBody; } } // empty body ("") is not allowed, make sure to use undefined instead if (_.isEmpty(rawBody)) { rawBody = undefined; } const fetchOptions = options?.fetchOptions ?? {}; const logOptions = options?.logOptions; try { responseObj.requestBody = fetchOptions && _.isString(fetchOptions?.body) ? JSON.parse(fetchOptions.body) : fetchOptions?.body; } catch { responseObj.requestBody = fetchOptions?.body; } // res.ok = response.ok, responseObj.method = fetchOptions?.method || response.method || "GET"; updateConsoleProps(responseObj, fetchOptions, logOptions, options.url, options.duration); // create a new window.Response for Client. this is required as the body // stream can not be read more than once. as we just read it, recreate the response // and resolve json() and text() promises using the values we read from the stream. const result = new Response(rawBody, { status: response.status, statusText: response.statusText, headers: response.headers, }); // pass the responseObj as part of the window.Response object. this way we can access // in the Clients response and do not need to reprocess result.responseObj = responseObj; result.requestBody = responseObj.requestBody; result.method = responseObj.method; result.data = responseObj.body; // result.json = () => Promise.resolve(responseObj.body); // result.text = () => Promise.resolve(rawBody || ""); return result; } function updateConsoleProps(responseObj, fetchOptions, logOptions, url, duration) { const authInfo = {}; const authorizationHeader = get_i(responseObj, "requestHeaders.authorization"); if (authorizationHeader) { const auth = getAuthOptionsFromBasicAuthHeader(authorizationHeader); if (auth?.user && auth?.password) { authInfo["Basicauth"] = `${authorizationHeader} (${auth.user})`; } else { if (authorizationHeader.startsWith("Bearer ")) { try { const jwt = authorizationHeader.replace("Bearer ", ""); const authOptions = getAuthOptionsFromJWT(jwt); authInfo["BearerAuth"] = authOptions; } catch { // ignore errors parsing JWT } } } } else { let token = get_i(responseObj, "requestHeaders.cookie.authorization"); if (!token) { token = get_i(responseObj, "requestHeaders.X-XSRF-TOKEN"); } // props["Options"] = options; if (token) { const loggedInUser = logOptions?.loggedInUser || ""; authInfo["CookieAuth"] = `${token} (${loggedInUser})`; } } // Call onRequestEnd callback if available if (logOptions?.onRequestEnd && logOptions?.contextId) { logOptions.onRequestEnd({ contextId: logOptions.contextId, url: toUrlString(url || responseObj.url || ""), method: fetchOptions?.method || responseObj.method || "GET", status: responseObj.status || 0, headers: responseObj.headers || {}, body: responseObj.body, duration: duration || responseObj.duration || 0, success: responseObj.isOkStatusCode || false, fetchOptions, options: logOptions.options, yielded: responseObj, additionalInfo: authInfo, }); } } /** * Converts the given object to a Cypress.Response. * @param obj The object to convert. * @param duration The duration of the request. * @param fetchOptions The fetch options used for the request. * @param url The URL of the request. * @param schema The schema of the response. */ export function toCypressResponse(obj, duration = 0, fetchOptions = {}, url, schema) { if (!obj) return undefined; if (typeof isPactRecord === "function" && isPactRecord(obj)) { return obj.toCypressResponse(); } let fetchResponse; if (isIResult(obj)) { fetchResponse = obj.res; } else if (isWindowFetchResponse(obj)) { fetchResponse = obj; } else { fetchResponse = obj; } if ("responseObj" in fetchResponse) { return _.get(fetchResponse, "responseObj"); } return { status: fetchResponse.status, isOkStatusCode: fetchResponse.ok || (fetchResponse.status > 199 && fetchResponse.status < 300), statusText: fetchResponse.statusText, headers: Object.fromEntries(fetchResponse.headers || []), requestHeaders: fetchOptions.headers, duration: duration, ...(url && { url: toUrlString(url) }), allRequestResponses: [], body: fetchResponse.data, requestBody: fetchResponse.requestBody, method: fetchResponse.method || "GET", ...(schema && { $body: schema }), }; } /** * Converts a Cypress.Response or C8yPactRecord to a window.Response. If * the given object is not a Cypress.Response or C8yPactRecord, undefined * is returned. * @param obj The object to check. */ export function toWindowFetchResponse(obj) { if (isPactRecord(obj)) { const body = _.isObjectLike(obj.response.body) ? JSON.stringify(obj.response.body) : obj.response.body; return new window.Response(body, { status: obj.response.status, statusText: obj.response.statusText, url: obj.request.url, ...(obj.response.headers && { headers: toResponseHeaders(obj.response.headers), }), ...(obj.request.url && { url: obj.request.url }), }); } if (isCypressResponse(obj)) { return new window.Response(obj.body, { status: obj.status, statusText: obj.statusText, headers: toResponseHeaders(obj.headers), ...(obj.url && { url: obj.url }), }); } return undefined; } /** * Converts the given headers to a window.Headers object. * @param headers The headers object to convert. */ export function toResponseHeaders(headers) { // type HeadersInit = [string, string][] | Record<string, string> | Headers; const arr = []; for (const [key, value] of Object.entries(headers)) { if (Array.isArray(value)) { value.forEach((v) => arr.push([key, v])); } else { arr.push([key, value]); } } return new Headers(arr); } /** * Checks if the given object is a window.Response. * @param obj The object to check. */ export function isWindowFetchResponse(obj) { return (obj != null && _.isObjectLike(obj) && "status" in obj && "statusText" in obj && "headers" in obj && "body" in obj && "url" in obj && _.isFunction(_.get(obj, "json")) && _.isFunction(_.get(obj, "arrayBuffer"))); } /** * Checks if the given object is an IResult. * @param obj The object to check. */ export function isIResult(obj) { return (obj != null && _.isObjectLike(obj) && "data" in obj && "res" in obj && isWindowFetchResponse(obj.res)); } /** * Checks if the given object is a CypressError. * @param error The object to check. * @returns True if the object is a CypressError, false otherwise. */ export function isCypressError(error) { return _.isError(error) && _.get(error, "name") === "CypressError"; } /** * Checks if the given object is a C8yClientError. * @param error The object to check. * @returns True if the object is a C8yClientError, false otherwise. */ export function isC8yClientError(error) { return _.isError(error) && _.get(error, "name") === "C8yClientError"; } export function throwC8yClientError(error, url = undefined, logOptions) { let errorMessage; if (error instanceof TypeError) { errorMessage = url ? `Network error occurred while making request to ${toUrlString(url || "")}: ${error.message}` : `Network error occurred while making request: ${error.message}`; } else { errorMessage = `Request failed: ${error.message}`; } // Call onRequestEnd if logging options are provided if (logOptions?.onRequestEnd && logOptions?.contextId) { const duration = logOptions.startTime ? Date.now() - logOptions.startTime : 0; logOptions.onRequestEnd({ contextId: logOptions.contextId, url: toUrlString(url || ""), method: logOptions.method || "GET", duration, success: false, options: logOptions?.options, error: errorMessage || error.message || error.toString(), }); } throw new C8yClientError(errorMessage, error); }