Feature: hopp-cli in TypeScript (#2074)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: liyasthomas <liyascthomas@gmail.com>
Co-authored-by: Gita Alekhya Paul <gitaalekhyapaul@gmail.com>
This commit is contained in:
Deepanshu Dhruw
2022-03-28 13:56:15 +05:30
committed by GitHub
parent cdf61079ae
commit 909d524de5
36 changed files with 2654 additions and 119 deletions

View File

@@ -0,0 +1,155 @@
import fs from "fs/promises";
import { join } from "path";
import { pipe } from "fp-ts/function";
import {
HoppCollection,
HoppRESTRequest,
isHoppRESTRequest,
} from "@hoppscotch/data";
import * as A from "fp-ts/Array";
import * as S from "fp-ts/string";
import * as TE from "fp-ts/TaskEither";
import { error, HoppCLIError, HoppErrnoException } from "../types/errors";
import { CommanderError } from "commander";
/**
* Determines whether an object has a property with given name.
* @param target Object to be checked for given property.
* @param prop Property to be checked in target object.
* @returns True, if property exists in target object; False, otherwise.
*/
export const hasProperty = <P extends PropertyKey>(
target: object,
prop: P
): target is Record<P, unknown> => prop in target;
/**
* Typeguard to check valid Hoppscotch REST Collection.
* @param param The object to be checked.
* @returns True, if unknown parameter is valid Hoppscotch REST Collection;
* False, otherwise.
*/
export const isRESTCollection = (
param: unknown
): param is HoppCollection<HoppRESTRequest> => {
if (!!param && typeof param === "object") {
if (!hasProperty(param, "v") || typeof param.v !== "number") {
return false;
}
if (!hasProperty(param, "name") || typeof param.name !== "string") {
return false;
}
if (hasProperty(param, "id") && typeof param.id !== "string") {
return false;
}
if (!hasProperty(param, "requests") || !Array.isArray(param.requests)) {
return false;
} else {
// Checks each requests array to be valid HoppRESTRequest.
const checkRequests = A.every(isHoppRESTRequest)(param.requests);
if (!checkRequests) {
return false;
}
}
if (!hasProperty(param, "folders") || !Array.isArray(param.folders)) {
return false;
} else {
// Checks each folder to be valid REST collection.
const checkFolders = A.every(isRESTCollection)(param.folders);
if (!checkFolders) {
return false;
}
}
return true;
}
return false;
};
/**
* Checks if the given file path exists and is of JSON type.
* @param path The input file path to check.
* @returns Absolute path for valid file path OR HoppCLIError in case of error.
*/
export const checkFilePath = (
path: string
): TE.TaskEither<HoppCLIError, string> =>
pipe(
path,
/**
* Check the path type and returns string if passes else HoppCLIError.
*/
TE.fromPredicate(S.isString, () => error({ code: "NO_FILE_PATH" })),
/**
* Trying to access given file path.
* If successfully accessed, we return the path from predicate step.
* Else return HoppCLIError with code FILE_NOT_FOUND.
*/
TE.chainFirstW(
TE.tryCatchK(
() => pipe(path, join, fs.access),
() => error({ code: "FILE_NOT_FOUND", path: path })
)
),
/**
* On successfully accessing given file path, we map file path to
* absolute path and return abs file path if file is JSON type.
*/
TE.map(join),
TE.chainW(
TE.fromPredicate(S.endsWith(".json"), (absPath) =>
error({ code: "FILE_NOT_JSON", path: absPath })
)
)
);
/**
* Checks if given error data is of type HoppCLIError, based on existence
* of code property.
* @param error Error data to check.
* @returns True, if unknown error validates to be HoppCLIError;
* False, otherwise.
*/
export const isHoppCLIError = (error: unknown): error is HoppCLIError => {
return (
!!error &&
typeof error === "object" &&
hasProperty(error, "code") &&
typeof error.code === "string"
);
};
/**
* Checks if given error data is of type HoppErrnoException, based on existence
* of name property.
* @param error Error data to check.
* @returns True, if unknown error validates to be HoppErrnoException;
* False, otherwise.
*/
export const isHoppErrnoException = (
error: unknown
): error is HoppErrnoException => {
return (
!!error &&
typeof error === "object" &&
hasProperty(error, "name") &&
typeof error.name === "string"
);
};
/**
* Check whether given unknown error is instance of commander-error and
* has zero exit code (which we consider as safe error).
* @param error Error data to check.
* @returns True, if error data validates to be safe-commander-error;
* False, otherwise.
*/
export const isSafeCommanderError = (
error: unknown
): error is CommanderError => {
return error instanceof CommanderError && error.exitCode === 0;
};

View File

@@ -0,0 +1,129 @@
import * as T from "fp-ts/Task";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import { bold } from "chalk";
import { log } from "console";
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { HoppEnvs, CollectionStack, RequestReport } from "../types/request";
import { preProcessRequest, processRequest } from "./request";
import { exceptionColors } from "./getters";
import { TestReport } from "../interfaces/response";
import {
printErrorsReport,
printFailedTestsReport,
printTestsMetrics,
} from "./display";
const { WARN, FAIL } = exceptionColors;
/**
* Processes each requests within collections to prints details of subsequent requests,
* tests and to display complete errors-report, failed-tests-report and test-metrics.
* @param collections Array of hopp-collection with hopp-requests to be processed.
* @returns List of report for each processed request.
*/
export const collectionsRunner =
(collections: HoppCollection<HoppRESTRequest>[]): T.Task<RequestReport[]> =>
async () => {
const envs: HoppEnvs = { global: [], selected: [] };
const requestsReport: RequestReport[] = [];
const collectionStack: CollectionStack[] = getCollectionStack(collections);
while (collectionStack.length) {
// Pop out top-most collection from stack to be processed.
const { collection, path } = <CollectionStack>collectionStack.pop();
// Processing each request in collection
for (const request of collection.requests) {
const _request = preProcessRequest(request);
const requestPath = `${path}/${_request.name}`;
// Request processing initiated message.
log(WARN(`\nRunning: ${bold(requestPath)}`));
// Processing current request.
const result = await processRequest(_request, envs, requestPath)();
// Updating global & selected envs with new envs from processed-request output.
const { global, selected } = result.envs;
envs.global = global;
envs.selected = selected;
// Storing current request's report.
const requestReport = result.report;
requestsReport.push(requestReport);
}
// Pushing remaining folders realted collection to stack.
for (const folder of collection.folders) {
collectionStack.push({
path: `${path}/${folder.name}`,
collection: folder,
});
}
}
return requestsReport;
};
/**
* Transforms collections to generate collection-stack which describes each collection's
* path within collection & the collection itself.
* @param collections Hopp-collection objects to be mapped to collection-stack type.
* @returns Mapped collections to collection-stack.
*/
const getCollectionStack = (
collections: HoppCollection<HoppRESTRequest>[]
): CollectionStack[] =>
pipe(
collections,
A.map(
(collection) => <CollectionStack>{ collection, path: collection.name }
)
);
/**
* Prints collection-runner-report using test-metrics data in table format.
* @param requestsReport Provides data for each request-report which includes
* failed-tests-report, errors
* @returns True, if collection runner executed without any errors or failed test-cases.
* False, if errors occured or test-cases failed.
*/
export const collectionsRunnerResult = (
requestsReport: RequestReport[]
): boolean => {
const testsReport: TestReport[] = [];
let finalResult = true;
// Printing requests-report details of failed-tests and errors
for (const requestReport of requestsReport) {
const { path, tests, errors, result } = requestReport;
finalResult = finalResult && result;
printFailedTestsReport(path, tests);
printErrorsReport(path, errors);
testsReport.push.apply(testsReport, tests);
}
printTestsMetrics(testsReport);
return finalResult;
};
/**
* Exiting hopp cli process with appropriate exit code depending on
* collections-runner result.
* If result is true, we exit the cli process with code 0.
* Else, exit with code 1.
* @param result Boolean defining the collections-runner result.
*/
export const collectionsRunnerExit = (result: boolean) => {
if (!result) {
const EXIT_MSG = FAIL(`\nExited with code 1`);
process.stdout.write(EXIT_MSG);
process.exit(1);
}
process.exit(0);
};

View File

@@ -0,0 +1,7 @@
import { ResponseErrorPair } from "../interfaces/response";
export const responseErrors: ResponseErrorPair = {
501: "REQUEST NOT SUPPORTED",
408: "NETWORK TIMEOUT",
400: "BAD REQUEST",
} as const;

View File

@@ -0,0 +1,145 @@
import { bold } from "chalk";
import { groupEnd, group, log } from "console";
import { handleError } from "../handlers/error";
import { RequestConfig } from "../interfaces/request";
import { RequestRunnerResponse, TestReport } from "../interfaces/response";
import { HoppCLIError } from "../types/errors";
import { exceptionColors, getColorStatusCode } from "./getters";
import {
getFailedExpectedResults,
getFailedTestsReport,
getTestMetrics,
} from "./test";
const { FAIL, SUCCESS, BG_INFO } = exceptionColors;
/**
* Prints test-suites in pretty-way describing each test-suites failed/passed
* status.
* @param testsReport Providing details of each test-suites with tests-report.
*/
export const printTestSuitesReport = (testsReport: TestReport[]) => {
group();
for (const testReport of testsReport) {
const { failing, descriptor } = testReport;
if (failing > 0) {
log(`${FAIL("✖")} ${descriptor}`);
} else {
log(`${SUCCESS("✔")} ${descriptor}`);
}
}
groupEnd();
};
/**
* Prints total number of test-cases and test-suites passed/failed.
* @param testsReport Provides testSuites and testCases metrics.
*/
export const printTestsMetrics = (testsReport: TestReport[]) => {
const { testSuites, tests } = getTestMetrics(testsReport);
const failedTestCasesOut = FAIL(`${tests.failing} failing`);
const passedTestCasesOut = SUCCESS(`${tests.passing} passing`);
const testCasesOut = `Test Cases: ${failedTestCasesOut} ${passedTestCasesOut}\n`;
const failedTestSuitesOut = FAIL(`${testSuites.failing} failing`);
const passedTestSuitesOut = SUCCESS(`${testSuites.passing} passing`);
const testSuitesOut = `Test Suites: ${failedTestSuitesOut} ${passedTestSuitesOut}\n`;
const message = `\n${testCasesOut}${testSuitesOut}`;
process.stdout.write(message);
};
/**
* Prints details of each reported error for a request with error code.
* @param path Request's path in collection for which errors occured.
* @param errorsReport List of errors reported.
*/
export const printErrorsReport = (
path: string,
errorsReport: HoppCLIError[]
) => {
if (errorsReport.length > 0) {
const REPORTED_ERRORS_TITLE = FAIL(`\n${bold(path)} reported errors:`);
group(REPORTED_ERRORS_TITLE);
for (const errorReport of errorsReport) {
handleError(errorReport);
}
groupEnd();
}
};
/**
* Prints details of each failed tests for given request's path.
* @param path Request's path in collection for which tests-failed.
* @param testsReport Overall tests-report including failed-tests-report.
*/
export const printFailedTestsReport = (
path: string,
testsReport: TestReport[]
) => {
const failedTestsReport = getFailedTestsReport(testsReport);
// Only printing test-reports with failing test-cases.
if (failedTestsReport.length > 0) {
const FAILED_TESTS_PATH = FAIL(`\n${bold(path)} failed tests:`);
group(FAILED_TESTS_PATH);
for (const failedTestReport of failedTestsReport) {
const { descriptor, expectResults } = failedTestReport;
const failedExpectResults = getFailedExpectedResults(expectResults);
// Only printing failed expected-results.
if (failedExpectResults.length > 0) {
group("⦁", descriptor);
for (const failedExpectResult of failedExpectResults) {
log(FAIL("-"), failedExpectResult.message);
}
groupEnd();
}
}
groupEnd();
}
};
/**
* Provides methods for printing request-runner's state messages.
*/
export const printRequestRunner = {
// Request-runner starting message.
start: (requestConfig: RequestConfig) => {
const METHOD = BG_INFO(` ${requestConfig.method} `);
const ENDPOINT = requestConfig.url;
process.stdout.write(`${METHOD} ${ENDPOINT}`);
},
// Prints response's status, when request-runner executes successfully.
success: (requestResponse: RequestRunnerResponse) => {
const { status, statusText } = requestResponse;
const statusMsg = getColorStatusCode(status, statusText);
process.stdout.write(` ${statusMsg}\n`);
},
// Prints error message, when request-runner fails to execute.
fail: () => log(FAIL(" ERROR\n⚠ Error running request.")),
};
/**
* Provides methods for printing test-runner's state messages.
*/
export const printTestRunner = {
fail: () => log(FAIL("⚠ Error running test-script.")),
};
/**
* Provides methods for printing pre-request-runner's state messages.
*/
export const printPreRequestRunner = {
fail: () => log(FAIL("⚠ Error running pre-request-script.")),
};

View File

@@ -0,0 +1,37 @@
import { clone } from "lodash";
/**
* Sorts the array based on the sort func.
* NOTE: Creates a new array, if you don't need ref
* to original array, use `arrayUnsafeSort` for better perf
* @param sortFunc Sort function to sort against
*/
export const arraySort =
<T>(sortFunc: (a: T, b: T) => number) =>
(arr: T[]) => {
const newArr = clone(arr);
newArr.sort(sortFunc);
return newArr;
};
/**
* Equivalent to `Array.prototype.flatMap`.
* @param mapFunc The map function.
* @returns Array formed by applying given mapFunc.
*/
export const arrayFlatMap =
<T, U>(mapFunc: (value: T, index: number, arr: T[]) => U[]) =>
(arr: T[]) =>
arr.flatMap(mapFunc);
export const tupleToRecord = <
KeyType extends string | number | symbol,
ValueType
>(
tuples: [KeyType, ValueType][]
): Record<KeyType, ValueType> =>
tuples.length > 0
? (Object.assign as any)(...tuples.map(([key, val]) => ({ [key]: val })))
: {};

View File

@@ -0,0 +1,113 @@
import {
HoppRESTHeader,
Environment,
parseTemplateStringE,
HoppRESTParam,
} from "@hoppscotch/data";
import chalk from "chalk";
import { pipe } from "fp-ts/function";
import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
import * as S from "fp-ts/string";
import * as O from "fp-ts/Option";
import { error } from "../types/errors";
/**
* Generates template string (status + statusText) with specific color unicodes
* based on type of status.
* @param status Status code of a HTTP response.
* @param statusText Status text of a HTTP response.
* @returns Template string with related color unicodes.
*/
export const getColorStatusCode = (
status: number | string,
statusText: string
): string => {
const statusCode = `${status == 0 ? "Error" : status} : ${statusText}`;
if (status.toString().startsWith("2")) {
return chalk.greenBright(statusCode);
} else if (status.toString().startsWith("3")) {
return chalk.yellowBright(statusCode);
}
return chalk.redBright(statusCode);
};
/**
* Replaces all template-string with their effective ENV values to generate effective
* request headers/parameters meta-data.
* @param metaData Headers/parameters on which ENVs will be applied.
* @param environment Provides ENV variables for parsing template-string.
* @returns Active, non-empty-key, parsed headers/parameters pairs.
*/
export const getEffectiveFinalMetaData = (
metaData: HoppRESTHeader[] | HoppRESTParam[],
environment: Environment
) =>
pipe(
metaData,
/**
* Selecting only non-empty and active pairs.
*/
A.filter(({ key, active }) => !S.isEmpty(key) && active),
A.map(({ key, value }) => ({
active: true,
key: parseTemplateStringE(key, environment.variables),
value: parseTemplateStringE(value, environment.variables),
})),
E.fromPredicate(
/**
* Check if every key-value is right either. Else return HoppCLIError with
* appropriate reason.
*/
A.every(({ key, value }) => E.isRight(key) && E.isRight(value)),
(reason) => error({ code: "PARSING_ERROR", data: reason })
),
E.map(
/**
* 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({ active: true, key: key.right, value: value.right })
: O.none
)
)
);
/**
* Reduces array of HoppRESTParam or HoppRESTHeader to unique key-value
* pair.
* @param metaData Array of meta-data to reduce.
* @returns Object with unique key-value pair.
*/
export const getMetaDataPairs = (
metaData: HoppRESTParam[] | HoppRESTHeader[]
) =>
pipe(
metaData,
// Excluding non-active & empty key request meta-data.
A.filter(({ active, key }) => active && !S.isEmpty(key)),
// Reducing array of request-meta-data to key-value pair object.
A.reduce(<Record<string, string>>{}, (target, { key, value }) =>
Object.assign(target, { [`${key}`]: value })
)
);
/**
* Object providing aliases for chalk color properties based on exceptions.
*/
export const exceptionColors = {
WARN: chalk.yellow,
INFO: chalk.blue,
FAIL: chalk.red,
SUCCESS: chalk.green,
BG_WARN: chalk.bgYellow,
BG_FAIL: chalk.bgRed,
BG_INFO: chalk.bgBlue,
BG_SUCCESS: chalk.bgGreen,
};

View File

@@ -0,0 +1,80 @@
import fs from "fs/promises";
import * as E from "fp-ts/Either";
import * as TE from "fp-ts/TaskEither";
import * as A from "fp-ts/Array";
import * as J from "fp-ts/Json";
import { pipe } from "fp-ts/function";
import { FormDataEntry } from "../types/request";
import { error, HoppCLIError } from "../types/errors";
import { isRESTCollection, isHoppErrnoException } from "./checks";
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
/**
* Parses array of FormDataEntry to FormData.
* @param values Array of FormDataEntry.
* @returns FormData with key-value pair from FormDataEntry.
*/
export const toFormData = (values: FormDataEntry[]) => {
const formData = new FormData();
values.forEach(({ key, value }) => formData.append(key, value));
return formData;
};
/**
* Parses provided error message to maintain hopp-error messages.
* @param e Custom error data.
* @returns Parsed error message without extra spaces.
*/
export const parseErrorMessage = (e: unknown) => {
let msg: string;
if (isHoppErrnoException(e)) {
msg = e.message.replace(e.code! + ":", "").replace("error:", "");
} else if (typeof e === "string") {
msg = e;
} else {
msg = JSON.stringify(e);
}
return msg.replace(/\n+$|\s{2,}/g, "").trim();
};
/**
* Parses collection json file for given path:context.path, and validates
* the parsed collectiona array.
* @param path Collection json file path.
* @returns For successful parsing we get array of HoppCollection<HoppRESTRequest>,
*/
export const parseCollectionData = (
path: string
): TE.TaskEither<HoppCLIError, HoppCollection<HoppRESTRequest>[]> =>
pipe(
// Trying to read give collection json path.
TE.tryCatch(
() => pipe(path, fs.readFile),
(reason) => error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
),
// Checking if parsed file data is array.
TE.chainEitherKW((data) =>
pipe(
data.toString(),
J.parse,
E.map((jsonData) => (Array.isArray(jsonData) ? jsonData : [jsonData])),
E.mapLeft((e) =>
error({ code: "MALFORMED_COLLECTION", path, data: E.toError(e) })
)
)
),
// Validating collections to be HoppRESTCollection.
TE.chainW(
TE.fromPredicate(A.every(isRESTCollection), () =>
error({
code: "MALFORMED_COLLECTION",
path,
data: "Please check the collection data.",
})
)
)
);

View File

@@ -0,0 +1,268 @@
import {
Environment,
HoppRESTRequest,
parseBodyEnvVariablesE,
parseRawKeyValueEntriesE,
parseTemplateString,
parseTemplateStringE,
} from "@hoppscotch/data";
import { runPreRequestScript } from "@hoppscotch/js-sandbox";
import { flow, pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";
import * as E from "fp-ts/Either";
import * as RA from "fp-ts/ReadonlyArray";
import * as A from "fp-ts/Array";
import * as O from "fp-ts/Option";
import * as S from "fp-ts/string";
import qs from "qs";
import { EffectiveHoppRESTRequest } from "../interfaces/request";
import { error, HoppCLIError } from "../types/errors";
import { HoppEnvs } from "../types/request";
import { isHoppCLIError } from "./checks";
import { tupleToRecord, arraySort, arrayFlatMap } from "./functions/array";
import { toFormData } from "./mutators";
import { getEffectiveFinalMetaData } from "./getters";
/**
* 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.
* @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
): TE.TaskEither<HoppCLIError, EffectiveHoppRESTRequest> =>
pipe(
TE.of(request),
TE.chain(({ preRequestScript }) =>
runPreRequestScript(preRequestScript, envs)
),
TE.map(
({ selected, global }) =>
<Environment>{ name: "Env", variables: [...selected, ...global] }
),
TE.chainEitherKW((env) => getEffectiveRESTRequest(request, env)),
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 function getEffectiveRESTRequest(
request: HoppRESTRequest,
environment: Environment
): E.Either<HoppCLIError, EffectiveHoppRESTRequest> {
const envVariables = environment.variables;
// Parsing final headers with applied ENVs.
const _effectiveFinalHeaders = getEffectiveFinalMetaData(
request.headers,
environment
);
if (E.isLeft(_effectiveFinalHeaders)) {
return _effectiveFinalHeaders;
}
const effectiveFinalHeaders = _effectiveFinalHeaders.right;
// Parsing final parameters with applied ENVs.
const _effectiveFinalParams = getEffectiveFinalMetaData(
request.params,
environment
);
if (E.isLeft(_effectiveFinalParams)) {
return _effectiveFinalParams;
}
const effectiveFinalParams = _effectiveFinalParams.right;
// Authentication
if (request.auth.authActive) {
// TODO: Support a better b64 implementation than btoa ?
if (request.auth.authType === "basic") {
const username = parseTemplateString(request.auth.username, envVariables);
const password = parseTemplateString(request.auth.password, envVariables);
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
});
} else if (
request.auth.authType === "bearer" ||
request.auth.authType === "oauth-2"
) {
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(
request.auth.token,
envVariables
)}`,
});
} else if (request.auth.authType === "api-key") {
const { key, value, addTo } = request.auth;
if (addTo === "Headers") {
effectiveFinalHeaders.push({
active: true,
key: parseTemplateString(key, envVariables),
value: parseTemplateString(value, envVariables),
});
} else if (addTo === "Query params") {
effectiveFinalParams.push({
active: true,
key: parseTemplateString(key, envVariables),
value: parseTemplateString(value, envVariables),
});
}
}
}
// Parsing final-body with applied ENVs.
const _effectiveFinalBody = getFinalBodyFromRequest(request, envVariables);
if (E.isLeft(_effectiveFinalBody)) {
return _effectiveFinalBody;
}
const effectiveFinalBody = _effectiveFinalBody.right;
if (request.body.contentType)
effectiveFinalHeaders.push({
active: true,
key: "content-type",
value: request.body.contentType,
});
// Parsing final-endpoint with applied ENVs.
const _effectiveFinalURL = parseTemplateStringE(
request.endpoint,
envVariables
);
if (E.isLeft(_effectiveFinalURL)) {
return E.left(
error({
code: "PARSING_ERROR",
data: `${request.endpoint} (${_effectiveFinalURL.left})`,
})
);
}
const effectiveFinalURL = _effectiveFinalURL.right;
return E.right({
...request,
effectiveFinalURL,
effectiveFinalHeaders,
effectiveFinalParams,
effectiveFinalBody,
});
}
/**
* 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 envVariables Provides set of key-value pairs (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,
envVariables: Environment["variables"]
): E.Either<HoppCLIError, string | null | FormData> {
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, envVariables),
parseTemplateStringE(value, envVariables),
]),
/**
* 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.map((v) => ({
key: parseTemplateString(x.key, envVariables),
value: v as string | Blob,
}))
: [
{
key: parseTemplateString(x.key, envVariables),
value: parseTemplateString(x.value, envVariables),
},
]
),
toFormData,
E.right
);
}
return pipe(
parseBodyEnvVariablesE(request.body.body, envVariables),
E.mapLeft((e) =>
error({
code: "PARSING_ERROR",
data: `${request.body.body} (${e})`,
})
)
);
}

View File

@@ -0,0 +1,321 @@
import axios, { Method } from "axios";
import { URL } from "url";
import * as S from "fp-ts/string";
import * as A from "fp-ts/Array";
import * as T from "fp-ts/Task";
import * as E from "fp-ts/Either";
import * as TE from "fp-ts/TaskEither";
import { HoppRESTRequest } from "@hoppscotch/data";
import { responseErrors } from "./constants";
import { getMetaDataPairs } from "./getters";
import { testRunner, getTestScriptParams, hasFailedTestCases } from "./test";
import { RequestConfig, EffectiveHoppRESTRequest } from "../interfaces/request";
import { RequestRunnerResponse } from "../interfaces/response";
import { preRequestScriptRunner } from "./pre-request";
import { HoppEnvs, RequestReport } from "../types/request";
import {
printPreRequestRunner,
printRequestRunner,
printTestRunner,
printTestSuitesReport,
} from "./display";
import { error, HoppCLIError } from "../types/errors";
// !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported
/**
* 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,
};
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 () => {
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;
const runnerResponse: RequestRunnerResponse = {
...baseResponse,
endpoint: getRequest.endpoint(config.url),
method: getRequest.method(config.method),
body: baseResponse.data,
};
// !NOTE: Temporary `config.supported` check
if ((config as RequestConfig).supported === false) {
status = 501;
runnerResponse.status = status;
runnerResponse.statusText = responseErrors[status];
}
return E.right(runnerResponse);
} catch (e) {
let status: number;
const runnerResponse: RequestRunnerResponse = {
endpoint: "",
method: "GET",
body: {},
statusText: responseErrors[400],
status: 400,
headers: [],
};
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) }));
}
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 =
(
request: HoppRESTRequest,
envs: HoppEnvs,
path: string
): T.Task<{ envs: HoppEnvs; report: RequestReport }> =>
async () => {
// 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,
};
// Initial value for effective-request with default values for properties.
let effectiveRequest = <EffectiveHoppRESTRequest>{
...request,
effectiveFinalBody: null,
effectiveFinalHeaders: [],
effectiveFinalParams: [],
effectiveFinalURL: "",
};
// Executing pre-request-script
const preRequestRes = await preRequestScriptRunner(request, envs)();
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
effectiveRequest = 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),
};
// Executing request-runner.
const requestRunnerRes = await requestRunner(requestConfig)();
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;
printRequestRunner.success(_requestRunnerRes);
}
// Extracting test-script-runner parameters.
const testScriptParams = getTestScriptParams(
_requestRunnerRes,
request,
envs
);
// 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 } = testRunnerRes.right;
const _hasFailedTestCases = hasFailedTestCases(testsReport);
// Updating report with current tests & result.
report.tests = testsReport;
report.result = report.result && _hasFailedTestCases;
// Updating resulting envs from test-runner.
result.envs = envs;
printTestSuitesReport(testsReport);
}
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
): HoppRESTRequest => {
const tempRequest = Object.assign({}, request);
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 (!tempRequest.headers) {
tempRequest.headers = [];
}
if (!tempRequest.preRequestScript) {
tempRequest.preRequestScript = "";
}
if (!tempRequest.testScript) {
tempRequest.testScript = "";
}
if (!tempRequest.auth) {
tempRequest.auth = { authActive: false, authType: "none" };
}
if (!tempRequest.body) {
tempRequest.body = { contentType: null, body: null };
}
return tempRequest;
};

View File

@@ -0,0 +1,197 @@
import { HoppRESTRequest } from "@hoppscotch/data";
import { execTestScript, TestDescriptor } from "@hoppscotch/js-sandbox";
import { flow, pipe } from "fp-ts/function";
import * as RA from "fp-ts/ReadonlyArray";
import * as A from "fp-ts/Array";
import * as TE from "fp-ts/TaskEither";
import * as T from "fp-ts/Task";
import {
RequestRunnerResponse,
TestReport,
TestScriptParams,
} from "../interfaces/response";
import { error, HoppCLIError } from "../types/errors";
import { HoppEnvs } from "../types/request";
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
/**
* Executes test script and runs testDescriptorParser to generate test-report using
* expected-results, test-status & test-descriptor.
* @param testScriptData Parameters related to test-script function.
* @returns If executes successfully, we get TestRunnerRes(updated ENVs + test-reports).
* Else, HoppCLIError with appropriate code & data.
*/
export const testRunner = (
testScriptData: TestScriptParams
): TE.TaskEither<HoppCLIError, TestRunnerRes> =>
pipe(
/**
* Executing test-script.
*/
TE.of(testScriptData),
TE.chain(({ testScript, response, envs }) =>
execTestScript(testScript, envs, response)
),
/**
* Recursively parsing test-results using test-descriptor-parser
* to generate test-reports.
*/
TE.chainTaskK(({ envs, tests }) =>
pipe(
tests,
A.map(testDescriptorParser),
T.sequenceArray,
T.map(
flow(
RA.flatten,
RA.toArray,
(testsReport) => <TestRunnerRes>{ envs, testsReport }
)
)
)
),
TE.mapLeft((e) =>
error({
code: "TEST_SCRIPT_ERROR",
data: e,
})
)
);
/**
* Recursive function to parse test-descriptor from nested-children and
* generate tests-report.
* @param testDescriptor Object with details of test-descriptor.
* @returns Flattened array of TestReport parsed from TestDescriptor.
*/
export const testDescriptorParser = (
testDescriptor: TestDescriptor
): T.Task<TestReport[]> =>
pipe(
/**
* Generate single TestReport from given testDescriptor.
*/
testDescriptor,
({ expectResults, descriptor }) =>
A.isNonEmpty(expectResults)
? pipe(
expectResults,
A.reduce({ failing: 0, passing: 0 }, (prev, { status }) =>
/**
* Incrementing number of passed test-cases if status is "pass",
* else, incrementing number of failed test-cases.
*/
status === "pass"
? { failing: prev.failing, passing: prev.passing + 1 }
: { failing: prev.failing + 1, passing: prev.passing }
),
({ failing, passing }) =>
<TestReport>{
failing,
passing,
descriptor,
expectResults,
},
Array.of
)
: [],
T.of,
/**
* Recursive call to testDescriptorParser on testDescriptor's children.
* The result is concated with previous testReport.
*/
T.chain((testReport) =>
pipe(
testDescriptor.children,
A.map(testDescriptorParser),
T.sequenceArray,
T.map(flow(RA.flatten, RA.toArray, A.concat(testReport)))
)
)
);
/**
* Extracts parameter object from request-runner's response, request and envs
* for test-runner.
* @param reqRunnerRes Provides response data.
* @param request Provides test-script data.
* @param envs Current ENVs state with-in collections-runner.
* @returns Object to be passed as parameter for test-runner
*/
export const getTestScriptParams = (
reqRunnerRes: RequestRunnerResponse,
request: HoppRESTRequest,
envs: HoppEnvs
) => {
const testScriptParams: TestScriptParams = {
testScript: request.testScript,
response: {
body: reqRunnerRes.body,
status: reqRunnerRes.status,
headers: reqRunnerRes.headers,
},
envs: envs,
};
return testScriptParams;
};
/**
* Combines quantitative details (test-cases passed/failed) of each test-report
* to generate TestMetrics object with total test-cases & total test-suites.
* @param testsReport Contains details of each test-report (failed/passed test-cases).
* @returns Object containing details of total test-cases passed/failed and
* total test-suites passed/failed.
*/
export const getTestMetrics = (testsReport: TestReport[]): TestMetrics =>
testsReport.reduce(
({ testSuites, tests }, testReport) => ({
tests: {
failing: tests.failing + testReport.failing,
passing: tests.passing + testReport.passing,
},
testSuites: {
failing: testSuites.failing + (testReport.failing > 0 ? 1 : 0),
passing: testSuites.passing + (testReport.failing === 0 ? 1 : 0),
},
}),
<TestMetrics>{
tests: { failing: 0, passing: 0 },
testSuites: { failing: 0, passing: 0 },
}
);
/**
* Filters tests-report containing atleast one or more failed test-cases.
* @param testsReport Provides "failing" test-cases data.
* @returns Tests report with one or more test-cases failing.
*/
export const getFailedTestsReport = (testsReport: TestReport[]) =>
pipe(
testsReport,
A.filter(({ failing }) => failing > 0)
);
/**
* Filters expected-results containing which has status as "fail" or "error".
* @param expectResults Provides "status" data for each expected result.
* @returns Expected results with "fail" or "error" status.
*/
export const getFailedExpectedResults = (expectResults: ExpectResult[]) =>
pipe(
expectResults,
A.filter(({ status }) => status !== "pass")
);
/**
* Checks if any of the tests-report have failed test-cases.
* @param testsReport Provides "failing" test-cases data.
* @returns True, if one or more failed test-cases found.
* False, if all test-cases passed.
*/
export const hasFailedTestCases = (testsReport: TestReport[]) =>
pipe(
testsReport,
A.every(({ failing }) => failing === 0)
);