Files
hoppscotch/packages/hoppscotch-cli/src/utils/request.ts
2024-03-20 00:18:03 +05:30

438 lines
14 KiB
TypeScript

import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import axios, { Method } from "axios";
import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither";
import { pipe } from "fp-ts/function";
import * as S from "fp-ts/string";
import { hrtime } from "process";
import { URL } from "url";
import { EffectiveHoppRESTRequest, RequestConfig } from "../interfaces/request";
import { RequestRunnerResponse } from "../interfaces/response";
import { HoppCLIError, error } from "../types/errors";
import {
HoppEnvs,
ProcessRequestParams,
RequestReport,
} from "../types/request";
import { RequestMetrics } from "../types/response";
import { responseErrors } from "./constants";
import {
printPreRequestRunner,
printRequestRunner,
printTestRunner,
} from "./display";
import { getDurationInSeconds, getMetaDataPairs } from "./getters";
import { preRequestScriptRunner } from "./pre-request";
import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test";
// !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported
/**
* Processes given variable, which includes checking for secret variables
* and getting value from system environment
* @param variable Variable to be processed
* @returns Updated variable with value from system environment
*/
const processVariables = (variable: Environment["variables"][number]) => {
if (variable.secret) {
return {
...variable,
value:
"value" in variable ? variable.value : process.env[variable.key] || "",
};
}
return variable;
};
/**
* Processes given envs, which includes processing each variable in global
* and selected envs
* @param envs Global + selected envs used by requests with in collection
* @returns Processed envs with each variable processed
*/
const processEnvs = (envs: HoppEnvs) => {
const processedEnvs = {
global: envs.global.map(processVariables),
selected: envs.selected.map(processVariables),
};
return processedEnvs;
};
/**
* Transforms given request data to request-config used by request-runner to
* perform HTTP request.
* @param req Effective request data with parsed ENVs.
* @returns Request config with data realted to HTTP request.
*/
export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => {
const config: RequestConfig = {
supported: true,
displayUrl: req.effectiveFinalDisplayURL,
};
const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest;
const reqParams = finalParams(req);
const reqHeaders = finalHeaders(req);
config.url = finalEndpoint(req);
config.method = req.method as Method;
config.params = getMetaDataPairs(reqParams);
config.headers = getMetaDataPairs(reqHeaders);
if (req.auth.authActive) {
switch (req.auth.authType) {
case "oauth-2": {
// TODO: OAuth2 Request Parsing
// !NOTE: Temporary `config.supported` check
config.supported = false;
}
default: {
break;
}
}
}
if (req.body.contentType) {
config.headers["Content-Type"] = req.body.contentType;
switch (req.body.contentType) {
case "multipart/form-data": {
// TODO: Parse Multipart Form Data
// !NOTE: Temporary `config.supported` check
config.supported = false;
break;
}
default: {
config.data = finalBody(req);
break;
}
}
}
return config;
};
/**
* Performs http request using axios with given requestConfig axios
* parameters.
* @param requestConfig The axios request config.
* @returns If successfully ran, we get runner-response including HTTP response data.
* Else, HoppCLIError with appropriate error code & data.
*/
export const requestRunner =
(
requestConfig: RequestConfig
): TE.TaskEither<HoppCLIError, RequestRunnerResponse> =>
async () => {
const start = hrtime();
try {
// NOTE: Temporary parsing check for request endpoint.
requestConfig.url = new URL(requestConfig.url ?? "").toString();
let status: number;
const baseResponse = await axios(requestConfig);
const { config } = baseResponse;
// PR-COMMENT: type error
const runnerResponse: RequestRunnerResponse = {
...baseResponse,
endpoint: getRequest.endpoint(config.url),
method: getRequest.method(config.method),
body: baseResponse.data,
duration: 0,
};
// !NOTE: Temporary `config.supported` check
if ((config as RequestConfig).supported === false) {
status = 501;
runnerResponse.status = status;
runnerResponse.statusText = responseErrors[status];
}
const end = hrtime(start);
const duration = getDurationInSeconds(end);
runnerResponse.duration = duration;
return E.right(runnerResponse);
} catch (e) {
let status: number;
const runnerResponse: RequestRunnerResponse = {
endpoint: "",
method: "GET",
body: {},
statusText: responseErrors[400],
status: 400,
headers: [],
duration: 0,
};
if (axios.isAxiosError(e)) {
runnerResponse.endpoint = e.config.url ?? "";
if (e.response) {
const { data, status, statusText, headers } = e.response;
runnerResponse.body = data;
runnerResponse.statusText = statusText;
runnerResponse.status = status;
runnerResponse.headers = headers;
} else if ((e.config as RequestConfig).supported === false) {
status = 501;
runnerResponse.status = status;
runnerResponse.statusText = responseErrors[status];
} else if (e.request) {
return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) }));
}
const end = hrtime(start);
const duration = getDurationInSeconds(end);
runnerResponse.duration = duration;
return E.right(runnerResponse);
}
return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) }));
}
};
/**
* Getter object methods for request-runner.
*/
const getRequest = {
method: (value: string | undefined) =>
value ? (value.toUpperCase() as Method) : "GET",
endpoint: (value: string | undefined): string => (value ? value : ""),
finalEndpoint: (req: EffectiveHoppRESTRequest): string =>
S.isEmpty(req.effectiveFinalURL) ? req.endpoint : req.effectiveFinalURL,
finalHeaders: (req: EffectiveHoppRESTRequest) =>
A.isNonEmpty(req.effectiveFinalHeaders)
? req.effectiveFinalHeaders
: req.headers,
finalParams: (req: EffectiveHoppRESTRequest) =>
A.isNonEmpty(req.effectiveFinalParams)
? req.effectiveFinalParams
: req.params,
finalBody: (req: EffectiveHoppRESTRequest) =>
req.effectiveFinalBody ? req.effectiveFinalBody : req.body.body,
};
/**
* Processes given request, which includes executing pre-request-script,
* running request & executing test-script.
* @param request Request to be processed.
* @param envs Global + selected envs used by requests with in collection.
* @returns Updated envs and current request's report.
*/
export const processRequest =
(
params: ProcessRequestParams
): T.Task<{ envs: HoppEnvs; report: RequestReport }> =>
async () => {
const { envs, path, request, delay } = params;
// Initialising updatedEnvs with given parameter envs, will eventually get updated.
const result = {
envs: <HoppEnvs>envs,
report: <RequestReport>{},
};
// Initial value for current request's report with default values for properties.
const report: RequestReport = {
path: path,
tests: [],
errors: [],
result: true,
duration: { test: 0, request: 0, preRequest: 0 },
};
// Initial value for effective-request with default values for properties.
let effectiveRequest = <EffectiveHoppRESTRequest>{
...request,
effectiveFinalBody: null,
effectiveFinalHeaders: [],
effectiveFinalParams: [],
effectiveFinalURL: "",
};
let updatedEnvs = <HoppEnvs>{};
// Fetch values for secret environment variables from system environment
const processedEnvs = processEnvs(envs);
// Executing pre-request-script
const preRequestRes = await preRequestScriptRunner(
request,
processedEnvs
)();
if (E.isLeft(preRequestRes)) {
printPreRequestRunner.fail();
// Updating report for errors & current result
report.errors.push(preRequestRes.left);
report.result = report.result && false;
} else {
// Updating effective-request and consuming updated envs after pre-request script execution
({ effectiveRequest, updatedEnvs } = preRequestRes.right);
}
// Creating request-config for request-runner.
const requestConfig = createRequest(effectiveRequest);
printRequestRunner.start(requestConfig);
// Default value for request-runner's response.
let _requestRunnerRes: RequestRunnerResponse = {
endpoint: "",
method: "GET",
headers: [],
status: 400,
statusText: "",
body: Object(null),
duration: 0,
};
// Executing request-runner.
const requestRunnerRes = await delayPromiseFunction<
E.Either<HoppCLIError, RequestRunnerResponse>
>(requestRunner(requestConfig), delay);
if (E.isLeft(requestRunnerRes)) {
// Updating report for errors & current result
report.errors.push(requestRunnerRes.left);
report.result = report.result && false;
printRequestRunner.fail();
} else {
_requestRunnerRes = requestRunnerRes.right;
report.duration.request = _requestRunnerRes.duration;
printRequestRunner.success(_requestRunnerRes);
}
// Extracting test-script-runner parameters.
const testScriptParams = getTestScriptParams(
_requestRunnerRes,
request,
updatedEnvs
);
// Executing test-runner.
const testRunnerRes = await testRunner(testScriptParams)();
if (E.isLeft(testRunnerRes)) {
printTestRunner.fail();
// Updating report with current errors & result.
report.errors.push(testRunnerRes.left);
report.result = report.result && false;
} else {
const { envs, testsReport, duration } = testRunnerRes.right;
const _hasFailedTestCases = hasFailedTestCases(testsReport);
// Updating report with current tests, result and duration.
report.tests = testsReport;
report.result = report.result && _hasFailedTestCases;
report.duration.test = duration;
// Updating resulting envs from test-runner.
result.envs = envs;
// Printing tests-report, when test-runner executes successfully.
printTestRunner.success(testsReport, duration);
}
result.report = report;
return result;
};
/**
* Generates new request without any missing/invalid data using
* current request object.
* @param request Hopp rest request to be processed.
* @returns Updated request object free of invalid/missing data.
*/
export const preProcessRequest = (
request: HoppRESTRequest,
collection: HoppCollection
): HoppRESTRequest => {
const tempRequest = Object.assign({}, request);
const { headers: parentHeaders, auth: parentAuth } = collection;
if (!tempRequest.v) {
tempRequest.v = "1";
}
if (!tempRequest.name) {
tempRequest.name = "Untitled Request";
}
if (!tempRequest.method) {
tempRequest.method = "GET";
}
if (!tempRequest.endpoint) {
tempRequest.endpoint = "";
}
if (!tempRequest.params) {
tempRequest.params = [];
}
if (parentHeaders?.length) {
// Filter out header entries present in the parent (folder/collection) under the same name
// This ensures the child headers take precedence over the parent headers
const filteredEntries = parentHeaders.filter((parentHeaderEntries) => {
return !tempRequest.headers.some(
(reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key
);
});
tempRequest.headers.push(...filteredEntries);
} else if (!tempRequest.headers) {
tempRequest.headers = [];
}
if (!tempRequest.preRequestScript) {
tempRequest.preRequestScript = "";
}
if (!tempRequest.testScript) {
tempRequest.testScript = "";
}
if (tempRequest.auth?.authType === "inherit") {
tempRequest.auth = parentAuth;
} else if (!tempRequest.auth) {
tempRequest.auth = { authActive: false, authType: "none" };
}
if (!tempRequest.body) {
tempRequest.body = { contentType: null, body: null };
}
return tempRequest;
};
/**
* Get request-metrics object (stats+duration) based on existence of REQUEST_ERROR code
* in hopp-errors list.
* @param errors List of errors to check for REQUEST_ERROR.
* @param duration Time taken (in seconds) to execute the request.
* @returns Object containing details of request's execution stats i.e., failed/passed
* data and duration.
*/
export const getRequestMetrics = (
errors: HoppCLIError[],
duration: number
): RequestMetrics =>
pipe(
errors,
A.some(({ code }) => code === "REQUEST_ERROR"),
(hasReqErrors) =>
hasReqErrors ? { failed: 1, passed: 0 } : { failed: 0, passed: 1 },
(requests) => <RequestMetrics>{ requests, duration }
);
/**
* A function to execute promises with specific delay in milliseconds.
* @param func Function with promise with return type T.
* @param delay TIme in milliseconds to delay function.
* @returns Promise of type same as func.
*/
export const delayPromiseFunction = <T>(
func: () => Promise<T>,
delay: number
): Promise<T> =>
new Promise((resolve) => setTimeout(() => resolve(func()), delay));