From 9bc81a6d67b059a19856fe59280e7c0557906fe7 Mon Sep 17 00:00:00 2001 From: James George Date: Thu, 14 Dec 2023 12:43:22 +0530 Subject: [PATCH] feat(cli): support collection level authorization and headers (#3636) --- .../src/__tests__/commands/test.spec.ts | 95 ++++---- .../collection-level-headers-auth.json | 221 ++++++++++++++++++ .../hoppscotch-cli/src/__tests__/utils.ts | 15 +- .../hoppscotch-cli/src/utils/collections.ts | 98 ++++---- packages/hoppscotch-cli/src/utils/request.ts | 48 ++-- 5 files changed, 373 insertions(+), 104 deletions(-) create mode 100644 packages/hoppscotch-cli/src/__tests__/samples/collection-level-headers-auth.json diff --git a/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts index acf805308..c706c7915 100644 --- a/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts @@ -1,63 +1,64 @@ import { ExecException } from "child_process"; + import { HoppErrorCode } from "../../types/errors"; -import { execAsync, getErrorCode, getTestJsonFilePath } from "../utils"; +import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils"; describe("Test 'hopp test ' command:", () => { test("No collection file path provided.", async () => { - const cmd = `node ./bin/hopp test`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = "test"; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Collection file not found.", async () => { - const cmd = `node ./bin/hopp test notfound.json`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = "test notfound.json"; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("FILE_NOT_FOUND"); }); test("Collection file is invalid JSON.", async () => { - const cmd = `node ./bin/hopp test ${getTestJsonFilePath( + const args = `test ${getTestJsonFilePath( "malformed-collection.json" )}`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("UNKNOWN_ERROR"); }); test("Malformed collection file.", async () => { - const cmd = `node ./bin/hopp test ${getTestJsonFilePath( + const args = `test ${getTestJsonFilePath( "malformed-collection2.json" )}`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("MALFORMED_COLLECTION"); }); test("Invalid arguement.", async () => { - const cmd = `node ./bin/hopp invalid-arg`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = "invalid-arg"; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Collection file not JSON type.", async () => { - const cmd = `node ./bin/hopp test ${getTestJsonFilePath("notjson.txt")}`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = `test ${getTestJsonFilePath("notjson.txt")}`; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("INVALID_FILE_TYPE"); }); test("Some errors occured (exit code 1).", async () => { - const cmd = `node ./bin/hopp test ${getTestJsonFilePath("fails.json")}`; - const { error } = await execAsync(cmd); + const args = `test ${getTestJsonFilePath("fails.json")}`; + const { error } = await runCLI(args); expect(error).not.toBeNull(); expect(error).toMatchObject({ @@ -66,75 +67,83 @@ describe("Test 'hopp test ' command:", () => { }); test("No errors occured (exit code 0).", async () => { - const cmd = `node ./bin/hopp test ${getTestJsonFilePath("passes.json")}`; - const { error } = await execAsync(cmd); + const args = `test ${getTestJsonFilePath("passes.json")}`; + const { error } = await runCLI(args); expect(error).toBeNull(); }); + + test("Supports inheriting headers and authorization set at the root collection", async () => { + const args = `test ${getTestJsonFilePath("collection-level-headers-auth.json")}`; + const { error } = await runCLI(args); + + expect(error).toBeNull(); + }) }); describe("Test 'hopp test --env ' command:", () => { - const VALID_TEST_CMD = `node ./bin/hopp test ${getTestJsonFilePath( + const VALID_TEST_ARGS = `test ${getTestJsonFilePath( "passes.json" )}`; test("No env file path provided.", async () => { - const cmd = `${VALID_TEST_CMD} --env`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = `${VALID_TEST_ARGS} --env`; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("ENV file not JSON type.", async () => { - const cmd = `${VALID_TEST_CMD} --env ${getTestJsonFilePath("notjson.txt")}`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath("notjson.txt")}`; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("INVALID_FILE_TYPE"); }); test("ENV file not found.", async () => { - const cmd = `${VALID_TEST_CMD} --env notfound.json`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = `${VALID_TEST_ARGS} --env notfound.json`; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("FILE_NOT_FOUND"); }); test("No errors occured (exit code 0).", async () => { const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json"); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json"); - const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`; - const { error, stdout } = await execAsync(cmd); + const args = `test ${TESTS_PATH} --env ${ENV_PATH}`; + const { error } = await runCLI(args); expect(error).toBeNull(); }); }); describe("Test 'hopp test --delay ' command:", () => { - const VALID_TEST_CMD = `node ./bin/hopp test ${getTestJsonFilePath( + const VALID_TEST_ARGS = `test ${getTestJsonFilePath( "passes.json" )}`; test("No value passed to delay flag.", async () => { - const cmd = `${VALID_TEST_CMD} --delay`; - const { stderr } = await execAsync(cmd); - const out = getErrorCode(stderr); + const args = `${VALID_TEST_ARGS} --delay`; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Invalid value passed to delay flag.", async () => { - const cmd = `${VALID_TEST_CMD} --delay 'NaN'`; - const { stderr } = await execAsync(cmd); + const args = `${VALID_TEST_ARGS} --delay 'NaN'`; + const { stderr } = await runCLI(args); + const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Valid value passed to delay flag.", async () => { - const cmd = `${VALID_TEST_CMD} --delay 1`; - const { error } = await execAsync(cmd); + const args = `${VALID_TEST_ARGS} --delay 1`; + const { error } = await runCLI(args); expect(error).toBeNull(); }); diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collection-level-headers-auth.json b/packages/hoppscotch-cli/src/__tests__/samples/collection-level-headers-auth.json new file mode 100644 index 000000000..9e6a5d631 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/collection-level-headers-auth.json @@ -0,0 +1,221 @@ +[ + { + "v": 1, + "name": "CollectionA", + "folders": [ + { + "v": 1, + "name": "FolderA", + "folders": [ + { + "v": 1, + "name": "FolderB", + "folders": [ + { + "v": 1, + "name": "FolderC", + "folders": [], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestD", + "params": [], + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Overriden at RequestD" + } + ], + "method": "GET", + "auth": { + "authType": "basic", + "authActive": true, + "username": "username", + "password": "password" + }, + "preRequestScript": "", + "testScript": "pw.test(\"Overrides auth and headers set at the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at RequestD\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n});", + "body": { + "contentType": null, + "body": null + } + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestC", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at FolderB\");\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"test-key\");\n});", + "body": { + "contentType": null, + "body": null + } + } + ], + "auth": { + "authType": "api-key", + "authActive": true, + "addTo": "Headers", + "key": "key", + "value": "test-key" + }, + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Overriden at FolderB" + } + ] + } + ], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestB", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestA", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Set at root collection" + } + ], + "auth": { + "authType": "bearer", + "authActive": true, + "token": "BearerToken" + } + }, + { + "v": 1, + "name": "CollectionB", + "folders": [ + { + "v": 1, + "name": "FolderA", + "folders": [], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestB", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] + } + ], + "requests": [ + { + "v": "1", + "endpoint": "https://echo.hoppscotch.io", + "name": "RequestA", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "inherit", + "authActive": true + }, + "preRequestScript": "", + "testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});", + "body": { + "contentType": null, + "body": null + }, + "id": "clpttpdq00003qp16kut6doqv" + } + ], + "headers": [ + { + "active": true, + "key": "X-Test-Header", + "value": "Set at root collection" + } + ], + "auth": { + "authType": "bearer", + "authActive": true, + "token": "BearerToken" + } + } +] \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/utils.ts b/packages/hoppscotch-cli/src/__tests__/utils.ts index 75a2a79e2..afa4494f5 100644 --- a/packages/hoppscotch-cli/src/__tests__/utils.ts +++ b/packages/hoppscotch-cli/src/__tests__/utils.ts @@ -1,10 +1,17 @@ import { exec } from "child_process"; +import { resolve } from "path"; + import { ExecResponse } from "./types"; -export const execAsync = (command: string): Promise => - new Promise((resolve) => - exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr })) - ); +export const runCLI = (args: string): Promise => + { + const CLI_PATH = resolve(__dirname, "../../bin/hopp"); + const command = `node ${CLI_PATH} ${args}` + + return new Promise((resolve) => + exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr })) + ); + } export const trimAnsi = (target: string) => { const ansiRegex = diff --git a/packages/hoppscotch-cli/src/utils/collections.ts b/packages/hoppscotch-cli/src/utils/collections.ts index f13245513..2880fd5fc 100644 --- a/packages/hoppscotch-cli/src/utils/collections.ts +++ b/packages/hoppscotch-cli/src/utils/collections.ts @@ -1,21 +1,23 @@ -import * as A from "fp-ts/Array"; -import { pipe } from "fp-ts/function"; +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; import { bold } from "chalk"; import { log } from "console"; +import * as A from "fp-ts/Array"; +import { pipe } from "fp-ts/function"; import round from "lodash/round"; -import { HoppCollection } from "@hoppscotch/data"; + +import { CollectionRunnerParam } from "../types/collections"; import { - HoppEnvs, CollectionStack, - RequestReport, + HoppEnvs, ProcessRequestParams, + RequestReport, } from "../types/request"; import { - getRequestMetrics, - preProcessRequest, - processRequest, -} from "./request"; -import { exceptionColors } from "./getters"; + PreRequestMetrics, + RequestMetrics, + TestMetrics, +} from "../types/response"; +import { DEFAULT_DURATION_PRECISION } from "./constants"; import { printErrorsReport, printFailedTestsReport, @@ -23,15 +25,14 @@ import { printRequestsMetrics, printTestsMetrics, } from "./display"; -import { - PreRequestMetrics, - RequestMetrics, - TestMetrics, -} from "../types/response"; -import { getTestMetrics } from "./test"; -import { DEFAULT_DURATION_PRECISION } from "./constants"; +import { exceptionColors } from "./getters"; import { getPreRequestMetrics } from "./pre-request"; -import { CollectionRunnerParam } from "../types/collections"; +import { + getRequestMetrics, + preProcessRequest, + processRequest, +} from "./request"; +import { getTestMetrics } from "./test"; const { WARN, FAIL } = exceptionColors; @@ -55,19 +56,19 @@ export const collectionsRunner = async ( // Pop out top-most collection from stack to be processed. const { collection, path } = collectionStack.pop(); - // Processing each request in collection - for (const request of collection.requests) { - const _request = preProcessRequest(request); - const requestPath = `${path}/${_request.name}`; - const processRequestParams: ProcessRequestParams = { - path: requestPath, - request: _request, - envs, - delay, - }; + // Processing each request in collection + for (const request of collection.requests) { + const _request = preProcessRequest(request as HoppRESTRequest, collection); + const requestPath = `${path}/${_request.name}`; + const processRequestParams: ProcessRequestParams = { + path: requestPath, + request: _request, + envs, + delay, + }; - // Request processing initiated message. - log(WARN(`\nRunning: ${bold(requestPath)}`)); + // Request processing initiated message. + log(WARN(`\nRunning: ${bold(requestPath)}`)); // Processing current request. const result = await processRequest(processRequestParams)(); @@ -77,19 +78,34 @@ export const collectionsRunner = async ( envs.global = global; envs.selected = selected; - // Storing current request's report. - const requestReport = result.report; - requestsReport.push(requestReport); - } + // 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, - }); + // Pushing remaining folders realted collection to stack. + for (const folder of collection.folders) { + const updatedFolder: HoppCollection = { ...folder } + + if (updatedFolder.auth?.authType === "inherit") { + updatedFolder.auth = collection.auth; + } + + if (collection.headers?.length) { + // Filter out header entries present in the parent collection under the same name + // This ensures the folder headers take precedence over the collection headers + const filteredHeaders = collection.headers.filter((collectionHeaderEntries) => { + return !updatedFolder.headers.some((folderHeaderEntries) => folderHeaderEntries.key === collectionHeaderEntries.key) + }) + updatedFolder.headers.push(...filteredHeaders); + } + + collectionStack.push({ + path: `${path}/${updatedFolder.name}`, + collection: updatedFolder, + }); + } } - } return requestsReport; }; diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index 82f002dff..343e0f199 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -1,31 +1,31 @@ +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; 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 T from "fp-ts/Task"; import * as TE from "fp-ts/TaskEither"; -import { HoppRESTRequest } from "@hoppscotch/data"; -import { responseErrors } from "./constants"; -import { getDurationInSeconds, getMetaDataPairs } from "./getters"; -import { testRunner, getTestScriptParams, hasFailedTestCases } from "./test"; -import { RequestConfig, EffectiveHoppRESTRequest } from "../interfaces/request"; +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 { preRequestScriptRunner } from "./pre-request"; +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 { error, HoppCLIError } from "../types/errors"; -import { hrtime } from "process"; -import { RequestMetrics } from "../types/response"; -import { pipe } from "fp-ts/function"; +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 @@ -309,9 +309,12 @@ export const processRequest = * @returns Updated request object free of invalid/missing data. */ export const preProcessRequest = ( - request: HoppRESTRequest + request: HoppRESTRequest, + collection: HoppCollection, ): HoppRESTRequest => { const tempRequest = Object.assign({}, request); + const { headers: parentHeaders, auth: parentAuth } = collection; + if (!tempRequest.v) { tempRequest.v = "1"; } @@ -327,18 +330,31 @@ export const preProcessRequest = ( if (!tempRequest.params) { tempRequest.params = []; } - if (!tempRequest.headers) { + + 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) { + + 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 }; }