import { Environment, EnvironmentVariable, HoppCollectionVariable, HoppRESTRequest, calculateHawkHeader, generateJWTToken, parseBodyEnvVariablesE, parseRawKeyValueEntriesE, parseTemplateString, parseTemplateStringE, } from "@hoppscotch/data"; import { runPreRequestScript } from "@hoppscotch/js-sandbox/node"; import { AwsV4Signer } from "aws4fetch"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; import * as O from "fp-ts/Option"; import * as RA from "fp-ts/ReadonlyArray"; import * as TE from "fp-ts/TaskEither"; import { flow, pipe } from "fp-ts/function"; import * as S from "fp-ts/string"; import qs from "qs"; import { createHoppFetchHook } from "./hopp-fetch"; import { EffectiveHoppRESTRequest } from "../interfaces/request"; import { HoppCLIError, error } from "../types/errors"; import { HoppEnvs } from "../types/request"; import { PreRequestMetrics } from "../types/response"; import { DigestAuthParams, fetchInitialDigestAuthInfo, generateDigestAuthHeader, } from "./auth/digest"; import { isHoppCLIError } from "./checks"; import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; import { stripComments } from "./jsonc"; import { toFormData } from "./mutators"; import { combineScriptsWithIIFE, filterValidScripts } from "./scripting"; /** * Runs pre-request-script runner over given request which extracts set ENVs and * applies them on current request to generate updated request. * @param request HoppRESTRequest to be converted to EffectiveHoppRESTRequest. * @param envs Environment variables related to request. * @param legacySandbox Whether to use the legacy sandbox. * @param collectionVariables Collection variables to use. * @param inheritedPreRequestScripts Pre-request scripts inherited from parent collections. * @returns EffectiveHoppRESTRequest that includes parsed ENV variables with in * request OR HoppCLIError with error code and related information. */ export const preRequestScriptRunner = ( request: HoppRESTRequest, envs: HoppEnvs, legacySandbox: boolean, collectionVariables?: HoppCollectionVariable[], inheritedPreRequestScripts: string[] = [] ): TE.TaskEither< HoppCLIError, { effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs } > => { const experimentalScriptingSandbox = !legacySandbox; const hoppFetchHook = createHoppFetchHook(); // Pre-request order: root → request. const combinedScript = combineScriptsWithIIFE( filterValidScripts([ ...inheritedPreRequestScripts, request.preRequestScript, ]), experimentalScriptingSandbox ? "experimental" : "legacy" ); return pipe( TE.of(request), TE.chain(() => runPreRequestScript(combinedScript, { envs, experimentalScriptingSandbox, request, cookies: null, hoppFetchHook, }) ), TE.map(({ updatedEnvs, updatedRequest }) => { const { selected, global } = updatedEnvs; return { // Keep the original updatedEnvs with separate global and selected arrays preRequestUpdatedEnvs: updatedEnvs, // Create Environment format for getEffectiveRESTRequest envForEffectiveRequest: { name: "Env", variables: [...(selected ?? []), ...(global ?? [])], }, updatedRequest: updatedRequest ?? {}, }; }), TE.chainW( ({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => { const finalRequest = { ...request, ...updatedRequest }; return TE.tryCatch( async () => { const result = await getEffectiveRESTRequest( finalRequest, envForEffectiveRequest, collectionVariables ); // Replace the updatedEnvs from getEffectiveRESTRequest with the one from pre-request script // This preserves the global/selected separation if (E.isRight(result)) { return E.right({ ...result.right, updatedEnvs: preRequestUpdatedEnvs, }); } return result; }, (reason) => error({ code: "PRE_REQUEST_SCRIPT_ERROR", data: reason }) ); } ), TE.chainEitherKW((effectiveRequest) => effectiveRequest), TE.mapLeft((reason) => isHoppCLIError(reason) ? reason : error({ code: "PRE_REQUEST_SCRIPT_ERROR", data: reason, }) ) ); }; /** * Outputs an executable request format with environment variables applied * * @param request The request to source from * @param environment The environment to apply * * @returns An object with extra fields defining a complete request */ export async function getEffectiveRESTRequest( request: HoppRESTRequest, environment: Environment, collectionVariables?: HoppCollectionVariable[] ): Promise< E.Either< HoppCLIError, { effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs } > > { const envVariables = environment.variables; const resolvedVariables = getResolvedVariables( request.requestVariables, envVariables, collectionVariables ); // Parsing final headers with applied ENVs. const _effectiveFinalHeaders = getEffectiveFinalMetaData( request.headers, resolvedVariables ); if (E.isLeft(_effectiveFinalHeaders)) { return _effectiveFinalHeaders; } const effectiveFinalHeaders = _effectiveFinalHeaders.right; // Parsing final parameters with applied ENVs. const _effectiveFinalParams = getEffectiveFinalMetaData( request.params, resolvedVariables ); if (E.isLeft(_effectiveFinalParams)) { return _effectiveFinalParams; } const effectiveFinalParams = _effectiveFinalParams.right; // Parsing final-body with applied ENVs. const _effectiveFinalBody = getFinalBodyFromRequest( request, resolvedVariables ); if (E.isLeft(_effectiveFinalBody)) { return _effectiveFinalBody; } // Authentication if (request.auth.authActive) { // TODO: Support a better b64 implementation than btoa ? if (request.auth.authType === "basic") { const username = parseTemplateString( request.auth.username, resolvedVariables ); const password = parseTemplateString( request.auth.password, resolvedVariables ); effectiveFinalHeaders.push({ active: true, key: "Authorization", value: `Basic ${btoa(`${username}:${password}`)}`, description: "", }); } else if (request.auth.authType === "bearer") { effectiveFinalHeaders.push({ active: true, key: "Authorization", value: `Bearer ${parseTemplateString(request.auth.token, resolvedVariables)}`, description: "", }); } else if (request.auth.authType === "oauth-2") { const { addTo } = request.auth; if (addTo === "HEADERS") { effectiveFinalHeaders.push({ active: true, key: "Authorization", value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, resolvedVariables)}`, description: "", }); } else if (addTo === "QUERY_PARAMS") { effectiveFinalParams.push({ active: true, key: "access_token", value: parseTemplateString( request.auth.grantTypeInfo.token, resolvedVariables ), description: "", }); } } else if (request.auth.authType === "api-key") { const { key, value, addTo } = request.auth; if (addTo === "HEADERS") { effectiveFinalHeaders.push({ active: true, key: parseTemplateString(key, resolvedVariables), value: parseTemplateString(value, resolvedVariables), description: "", }); } else if (addTo === "QUERY_PARAMS") { effectiveFinalParams.push({ active: true, key: parseTemplateString(key, resolvedVariables), value: parseTemplateString(value, resolvedVariables), description: "", }); } } else if (request.auth.authType === "aws-signature") { const { addTo } = request.auth; const currentDate = new Date(); const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, ""); const { method, endpoint } = request; const body = getFinalBodyFromRequest(request, resolvedVariables); const signer = new AwsV4Signer({ method, body: E.isRight(body) ? body.right?.toString() : undefined, datetime: amzDate, signQuery: addTo === "QUERY_PARAMS", accessKeyId: parseTemplateString( request.auth.accessKey, resolvedVariables ), secretAccessKey: parseTemplateString( request.auth.secretKey, resolvedVariables ), region: parseTemplateString(request.auth.region, resolvedVariables) ?? "us-east-1", service: parseTemplateString( request.auth.serviceName, resolvedVariables ), url: parseTemplateString(endpoint, resolvedVariables), sessionToken: request.auth.serviceToken && parseTemplateString(request.auth.serviceToken, resolvedVariables), }); const sign = await signer.sign(); if (addTo === "HEADERS") { sign.headers.forEach((value, key) => { effectiveFinalHeaders.push({ active: true, key, value, description: "", }); }); } else if (addTo === "QUERY_PARAMS") { sign.url.searchParams.forEach((value, key) => { effectiveFinalParams.push({ active: true, key, value, description: "", }); }); } } else if (request.auth.authType === "digest") { const { method, endpoint } = request as HoppRESTRequest; // Step 1: Fetch the initial auth info (nonce, realm, etc.) const authInfo = await fetchInitialDigestAuthInfo( parseTemplateString(endpoint, resolvedVariables), method, request.auth.disableRetry ); // Step 2: Set up the parameters for the digest authentication header const digestAuthParams: DigestAuthParams = { username: parseTemplateString(request.auth.username, resolvedVariables), password: parseTemplateString(request.auth.password, resolvedVariables), realm: request.auth.realm ? parseTemplateString(request.auth.realm, resolvedVariables) : authInfo.realm, nonce: request.auth.nonce ? parseTemplateString(authInfo.nonce, resolvedVariables) : authInfo.nonce, endpoint: parseTemplateString(endpoint, resolvedVariables), method, algorithm: request.auth.algorithm ?? authInfo.algorithm, qop: request.auth.qop ? parseTemplateString(request.auth.qop, resolvedVariables) : authInfo.qop, opaque: request.auth.opaque ? parseTemplateString(request.auth.opaque, resolvedVariables) : authInfo.opaque, reqBody: typeof request.body.body === "string" ? request.body.body : "", }; // Step 3: Generate the Authorization header const authHeaderValue = await generateDigestAuthHeader(digestAuthParams); effectiveFinalHeaders.push({ active: true, key: "Authorization", value: authHeaderValue, description: "", }); } else if (request.auth.authType === "hawk") { const { method, endpoint } = request; const hawkHeader = await calculateHawkHeader({ url: parseTemplateString(endpoint, resolvedVariables), // URL method: method, // HTTP method id: parseTemplateString(request.auth.authId, resolvedVariables), key: parseTemplateString(request.auth.authKey, resolvedVariables), algorithm: request.auth.algorithm, // advanced parameters (optional) includePayloadHash: request.auth.includePayloadHash, nonce: request.auth.nonce ? parseTemplateString(request.auth.nonce, resolvedVariables) : undefined, ext: request.auth.ext ? parseTemplateString(request.auth.ext, resolvedVariables) : undefined, app: request.auth.app ? parseTemplateString(request.auth.app, resolvedVariables) : undefined, dlg: request.auth.dlg ? parseTemplateString(request.auth.dlg, resolvedVariables) : undefined, timestamp: request.auth.timestamp ? parseInt( parseTemplateString(request.auth.timestamp, resolvedVariables), 10 ) : undefined, }); effectiveFinalHeaders.push({ active: true, key: "Authorization", value: hawkHeader, description: "", }); } else if (request.auth.authType === "jwt") { const { addTo } = request.auth; // Generate JWT token const token = await generateJWTToken({ algorithm: request.auth.algorithm || "HS256", secret: parseTemplateString(request.auth.secret, resolvedVariables), privateKey: parseTemplateString( request.auth.privateKey, resolvedVariables ), payload: parseTemplateString(request.auth.payload, resolvedVariables), jwtHeaders: parseTemplateString( request.auth.jwtHeaders, resolvedVariables ), isSecretBase64Encoded: request.auth.isSecretBase64Encoded, }); if (token) { if (addTo === "HEADERS") { const headerPrefix = parseTemplateString(request.auth.headerPrefix, resolvedVariables) || "Bearer "; effectiveFinalHeaders.push({ active: true, key: "Authorization", value: `${headerPrefix}${token}`, description: "", }); } else if (addTo === "QUERY_PARAMS") { const paramName = parseTemplateString(request.auth.paramName, resolvedVariables) || "token"; effectiveFinalParams.push({ active: true, key: paramName, value: token, description: "", }); } } } } const effectiveFinalBody = _effectiveFinalBody.right; if ( request.body.contentType && !effectiveFinalHeaders.some( ({ key }) => key.toLowerCase() === "content-type" ) ) { effectiveFinalHeaders.push({ active: true, key: "Content-Type", value: request.body.contentType, description: "", }); } // Parsing final-endpoint with applied ENVs (environment + request variables). const _effectiveFinalURL = parseTemplateStringE( request.endpoint, resolvedVariables ); if (E.isLeft(_effectiveFinalURL)) { return E.left( error({ code: "PARSING_ERROR", data: `${request.endpoint} (${_effectiveFinalURL.left})`, }) ); } const effectiveFinalURL = _effectiveFinalURL.right; // Secret environment variables referenced in the request endpoint should be masked let effectiveFinalDisplayURL; if (envVariables.some(({ secret }) => secret)) { const _effectiveFinalDisplayURL = parseTemplateStringE( request.endpoint, resolvedVariables, true ); if (E.isRight(_effectiveFinalDisplayURL)) { effectiveFinalDisplayURL = _effectiveFinalDisplayURL.right; } } return E.right({ effectiveRequest: { ...request, effectiveFinalURL, effectiveFinalDisplayURL, effectiveFinalHeaders, effectiveFinalParams, effectiveFinalBody, }, updatedEnvs: { global: [], selected: resolvedVariables }, }); } /** * Replaces template variables in request's body from the given set of ENVs, * to generate final request body without any template variables. * @param request Provides request's body, on which ENVs has to be applied. * @param resolvedVariables Provides set of key-value pairs (request + environment variables), * used to parse-out template variables. * @returns Final request body without any template variables as value. * Or, HoppCLIError in case of error while parsing. */ function getFinalBodyFromRequest( request: HoppRESTRequest, resolvedVariables: EnvironmentVariable[] ): E.Either { if (request.body.contentType === null) { return E.right(null); } if (request.body.contentType === "application/x-www-form-urlencoded") { return pipe( request.body.body, parseRawKeyValueEntriesE, E.map( flow( RA.toArray, /** * Filtering out empty keys and non-active pairs. */ A.filter(({ active, key }) => active && !S.isEmpty(key)), /** * Mapping each key-value to template-string-parser with either on array, * which will be resolved in further steps. */ A.map(({ key, value }) => [ parseTemplateStringE(key, resolvedVariables), parseTemplateStringE(value, resolvedVariables), ]), /** * Filtering and mapping only right-eithers for each key-value as [string, string]. */ A.filterMap(([key, value]) => E.isRight(key) && E.isRight(value) ? O.some([key.right, value.right] as [string, string]) : O.none ), tupleToRecord, qs.stringify ) ), E.mapLeft((e) => error({ code: "PARSING_ERROR", data: e.message })) ); } if (request.body.contentType === "multipart/form-data") { return pipe( request.body.body, A.filter((x) => x.key !== "" && x.active), // Remove empty keys // Sort files down arraySort((a, b) => { if (a.isFile) return 1; if (b.isFile) return -1; return 0; }), // FormData allows only a single blob in an entry, // we split array blobs into separate entries (FormData will then join them together during exec) arrayFlatMap((x) => x.isFile ? (x.value as (Blob | null)[]).map((v: Blob | null) => ({ key: parseTemplateString(x.key, resolvedVariables), value: v as string | Blob, contentType: x.contentType, })) : [ { key: parseTemplateString(x.key, resolvedVariables), value: parseTemplateString(x.value, resolvedVariables), contentType: x.contentType, }, ] ), toFormData, E.right ); } if (request.body.contentType === "application/octet-stream") { const body = request.body.body; if (!body) { return E.right(null); } if (!(body instanceof File)) { return E.right(null); } return E.right(body); } // For JSON content types, parse the string body into a JavaScript object // so axios can properly serialize it. This includes standard application/json // and vendor-specific JSON media types (for example those with a +json suffix // or subtypes whose names end with "json" or "-json"). if (request.body.contentType) { const mimeType = request.body.contentType .split(";")[0] .trim() .toLowerCase(); if ( mimeType === "application/json" || mimeType.endsWith("+json") || mimeType.endsWith("/json") || mimeType.endsWith("-json") ) { const envResult = parseBodyEnvVariablesE( request.body.body, resolvedVariables ); if (E.isLeft(envResult)) { return E.left( error({ code: "PARSING_ERROR", data: `${request.body.body} (${envResult.left})`, }) ); } const bodyString = envResult.right; // If the body string is empty or null, return null if (!bodyString || S.isEmpty(bodyString.trim())) { return E.right(null); } // Strip comments and trailing commas from JSONC // This ensures collections with comments work the same in CLI as in desktop app const cleanedBody = stripComments(bodyString); // Try to parse the JSON body try { const parsedBody = JSON.parse(cleanedBody); return E.right(JSON.stringify(parsedBody)); } catch (err) { // If parsing fails after stripping comments, return error to provide // immediate feedback instead of sending invalid JSON to the API. // Use original template string to avoid leaking secrets from env vars. return E.left( error({ code: "PARSING_ERROR", data: `${request.body.body} (Invalid JSON in request body: ${err instanceof Error ? err.message : String(err)})`, }) ); } } } return pipe( parseBodyEnvVariablesE(request.body.body, resolvedVariables), E.mapLeft((e) => error({ code: "PARSING_ERROR", data: `${request.body.body} (${e})`, }) ) ); } /** * Get pre-request-metrics (stats + duration) object based on existence of * PRE_REQUEST_ERROR code in given hopp-error list. * @param errors List of errors to check for PRE_REQUEST_ERROR code. * @param duration Time taken (in seconds) to execute the pre-request-script. * @returns Object containing details of pre-request-script's execution stats * i.e., failed/passed data and duration. */ export const getPreRequestMetrics = ( errors: HoppCLIError[], duration: number ): PreRequestMetrics => pipe( errors, A.some(({ code }) => code === "PRE_REQUEST_SCRIPT_ERROR"), (hasPreReqErrors) => hasPreReqErrors ? { failed: 1, passed: 0 } : { failed: 0, passed: 1 }, (scripts) => { scripts, duration } );