From 4dc90305590c595c9455a1c4dd2e3f3f3d3171c1 Mon Sep 17 00:00:00 2001 From: jamesgeorge007 Date: Sat, 25 May 2024 15:25:19 +0530 Subject: [PATCH] feat: access team workspace collections and environments from the CLI --- .../src/__tests__/commands/test.spec.ts | 119 +++++++++++++++- packages/hoppscotch-cli/src/commands/test.ts | 44 +++--- packages/hoppscotch-cli/src/handlers/error.ts | 9 ++ packages/hoppscotch-cli/src/index.ts | 16 ++- .../hoppscotch-cli/src/options/test/env.ts | 95 ++++++++++++- packages/hoppscotch-cli/src/types/commands.ts | 2 + packages/hoppscotch-cli/src/types/errors.ts | 3 + packages/hoppscotch-cli/src/utils/mutators.ts | 134 ++++++++++++++++-- 8 files changed, 382 insertions(+), 40 deletions(-) diff --git a/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts index e65fac753..3d5bcad72 100644 --- a/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts @@ -3,7 +3,7 @@ import { ExecException } from "child_process"; import { HoppErrorCode } from "../../types/errors"; import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils"; -describe("Test `hopp test ` command:", () => { +describe("Test `hopp test ` command:", () => { describe("Argument parsing", () => { test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => { const args = "test"; @@ -131,7 +131,7 @@ describe("Test `hopp test ` command:", () => { }); }); -describe("Test `hopp test --env ` command:", () => { +describe("Test `hopp test --env ` command:", () => { describe("Supplied environment export file validations", () => { const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; @@ -310,7 +310,7 @@ describe("Test `hopp test --env ` command:", () => { }); }); -describe("Test `hopp test --delay ` command:", () => { +describe("Test `hopp test --delay ` command:", () => { const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => { @@ -343,3 +343,116 @@ describe("Test `hopp test --delay ` command:", () => { expect(error).toBeNull(); }); }); + +describe.skip("Test `hopp test --env --token --server ` command:", () => { + const { + REQ_BODY_ENV_VARS_COLL_ID, + COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID, + REQ_BODY_ENV_VARS_ENVS_ID, + PERSONAL_ACCESS_TOKEN, + } = process.env; + + if ( + !REQ_BODY_ENV_VARS_COLL_ID || + !COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID || + !REQ_BODY_ENV_VARS_ENVS_ID || + !PERSONAL_ACCESS_TOKEN + ) { + return; + } + + const SERVER_URL = "https://stage-shc.hoppscotch.io/backend"; + + describe("Validations", () => { + test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--token` flag", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--server` flag", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --server`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ARGUMENT"); + }); + + test("Errors with the code `TOKEN_INVALID` if the supplied access token is invalid", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token invalid-token --server ${SERVER_URL}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("TOKEN_INVALID"); + }); + + test("Errors with the code `TOKEN_INVALID` if the supplied collection ID is invalid", async () => { + const args = `test invalid-id --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ID"); + }); + + test("Errors with the code `TOKEN_INVALID` if the supplied environment ID is invalid", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env invalid-id --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + const { stderr } = await runCLI(args); + + const out = getErrorCode(stderr); + expect(out).toBe("INVALID_ID"); + }); + }); + + test("Successfully retrieves a collection with the ID", async () => { + const args = `test ${COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Successfully retrieves collections and environments from a workspace using their respective IDs", async () => { + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Supports specifying collection file path along with environment ID", async () => { + const TESTS_PATH = getTestJsonFilePath( + "req-body-env-vars-coll.json", + "collection" + ); + const args = `test ${TESTS_PATH} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Supports specifying environment file path along with collection ID", async () => { + const ENV_PATH = getTestJsonFilePath( + "req-body-env-vars-envs.json", + "environment" + ); + const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); + + test("Supports specifying both collection and environment file paths", async () => { + const TESTS_PATH = getTestJsonFilePath( + "req-body-env-vars-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath( + "req-body-env-vars-envs.json", + "environment" + ); + const args = `test ${TESTS_PATH} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN}`; + + const { error } = await runCLI(args); + expect(error).toBeNull(); + }); +}); diff --git a/packages/hoppscotch-cli/src/commands/test.ts b/packages/hoppscotch-cli/src/commands/test.ts index bdc563892..18c16d45a 100644 --- a/packages/hoppscotch-cli/src/commands/test.ts +++ b/packages/hoppscotch-cli/src/commands/test.ts @@ -1,30 +1,38 @@ +import { handleError } from "../handlers/error"; +import { parseDelayOption } from "../options/test/delay"; +import { parseEnvsData } from "../options/test/env"; +import { TestCmdOptions } from "../types/commands"; +import { HoppEnvs } from "../types/request"; +import { isHoppCLIError } from "../utils/checks"; import { collectionsRunner, collectionsRunnerExit, collectionsRunnerResult, } from "../utils/collections"; -import { handleError } from "../handlers/error"; import { parseCollectionData } from "../utils/mutators"; -import { parseEnvsData } from "../options/test/env"; -import { TestCmdOptions } from "../types/commands"; -import { parseDelayOption } from "../options/test/delay"; -import { HoppEnvs } from "../types/request"; -import { isHoppCLIError } from "../utils/checks"; -export const test = (path: string, options: TestCmdOptions) => async () => { +export const test = (pathOrId: string, options: TestCmdOptions) => async () => { try { - const delay = options.delay ? parseDelayOption(options.delay) : 0 - const envs = options.env ? await parseEnvsData(options.env) : { global: [], selected: [] } - const collections = await parseCollectionData(path) + const delay = options.delay ? parseDelayOption(options.delay) : 0; - const report = await collectionsRunner({collections, envs, delay}) - const hasSucceeded = collectionsRunnerResult(report) - collectionsRunnerExit(hasSucceeded) - } catch(e) { - if(isHoppCLIError(e)) { - handleError(e) + const envs = options.env + ? await parseEnvsData(options.env, options.token, options.server) + : { global: [], selected: [] }; + + const collections = await parseCollectionData( + pathOrId, + options.token, + options.server + ); + + const report = await collectionsRunner({ collections, envs, delay }); + const hasSucceeded = collectionsRunnerResult(report); + + collectionsRunnerExit(hasSucceeded); + } catch (e) { + if (isHoppCLIError(e)) { + handleError(e); process.exit(1); - } - else throw e + } else throw e; } }; diff --git a/packages/hoppscotch-cli/src/handlers/error.ts b/packages/hoppscotch-cli/src/handlers/error.ts index 34430cf48..7d56d3ff8 100644 --- a/packages/hoppscotch-cli/src/handlers/error.ts +++ b/packages/hoppscotch-cli/src/handlers/error.ts @@ -82,6 +82,15 @@ export const handleError = (error: HoppError) => { case "TESTS_FAILING": ERROR_MSG = error.data; break; + case "TOKEN_EXPIRED": + ERROR_MSG = `Token is expired: ${error.data}`; + break; + case "TOKEN_INVALID": + ERROR_MSG = `Token is invalid/removed: ${error.data}`; + break; + case "INVALID_ID": + ERROR_MSG = `The collection/environment is not valid/not accessible to the user: ${error.data}`; + break; } if (!S.isEmpty(ERROR_MSG)) { diff --git a/packages/hoppscotch-cli/src/index.ts b/packages/hoppscotch-cli/src/index.ts index daa71995a..f5af6fd18 100644 --- a/packages/hoppscotch-cli/src/index.ts +++ b/packages/hoppscotch-cli/src/index.ts @@ -49,14 +49,22 @@ program.exitOverride().configureOutput({ program .command("test") .argument( - "", - "path to a hoppscotch collection.json file for CI testing" + "", + "path to a hoppscotch collection.json file or collection ID from a workspace for CI testing" + ) + .option( + "-e, --env ", + "path to an environment variables json file or environment ID from a workspace" ) - .option("-e, --env ", "path to an environment variables json file") .option( "-d, --delay ", "delay in milliseconds(ms) between consecutive requests within a collection" ) + .option( + "--token ", + "personal access token to access collections/environments from a workspace" + ) + .option("--server ", "server URL for SH instance") .allowExcessArguments(false) .allowUnknownOption(false) .description("running hoppscotch collection.json file") @@ -66,7 +74,7 @@ program "https://docs.hoppscotch.io/documentation/clients/cli#commands" )}` ) - .action(async (path, options) => await test(path, options)()); + .action(async (pathOrId, options) => await test(pathOrId, options)()); export const cli = async (args: string[]) => { try { diff --git a/packages/hoppscotch-cli/src/options/test/env.ts b/packages/hoppscotch-cli/src/options/test/env.ts index 2cec231a1..798afa51e 100644 --- a/packages/hoppscotch-cli/src/options/test/env.ts +++ b/packages/hoppscotch-cli/src/options/test/env.ts @@ -1,4 +1,6 @@ -import { Environment } from "@hoppscotch/data"; +import { Environment, EnvironmentSchemaVersion } from "@hoppscotch/data"; +import axios, { AxiosError } from "axios"; +import fs from "fs/promises"; import { entityReference } from "verzod"; import { z } from "zod"; @@ -10,13 +12,94 @@ import { } from "../../types/request"; import { readJsonFile } from "../../utils/mutators"; +interface WorkspaceEnvironment { + id: string; + teamID: string; + name: string; + variables: HoppEnvPair[]; +} + +// Helper functions to transform workspace environment data to the `HoppEnvironment` format +const transformWorkspaceEnvironment = ( + workspaceEnvironment: WorkspaceEnvironment +): Environment => { + const { teamID, variables, ...rest } = workspaceEnvironment; + + // Add `secret` field if the data conforms to an older schema + const transformedEnvVars = variables.map((variable) => { + if (!("secret" in variable)) { + return { + ...(variable as HoppEnvPair), + secret: false, + } as HoppEnvPair; + } + + return variable; + }); + + return { + v: EnvironmentSchemaVersion, + variables: transformedEnvVars, + ...rest, + }; +}; + /** * Parses env json file for given path and validates the parsed env json object - * @param path Path of env.json file to be parsed + * @param pathOrId Path of env.json file to be parsed + * @param [accessToken] Personal access token to fetch workspace environments + * @param [serverUrl] server URL for SH instance * @returns For successful parsing we get HoppEnvs object */ -export async function parseEnvsData(path: string) { - const contents = await readJsonFile(path); +export async function parseEnvsData( + pathOrId: string, + accessToken?: string, + serverUrl?: string +) { + let contents = null; + let fileExistsInPath = null; + + try { + await fs.access(pathOrId); + fileExistsInPath = true; + } catch (e) { + fileExistsInPath = false; + } + + if (accessToken && !fileExistsInPath) { + try { + const hostname = serverUrl || "https://api.hoppscotch.io"; + const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/environment/${pathOrId}`; + + const { data }: { data: WorkspaceEnvironment } = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + contents = transformWorkspaceEnvironment(data); + } catch (err) { + const errReason = ( + err as AxiosError<{ + reason?: any; + message: string; + error: string; + statusCode: number; + }> + ).response?.data?.reason; + + if (errReason) { + throw error({ + code: errReason, + data: pathOrId, + }); + } + } + } + + if (contents === null) { + contents = await readJsonFile(pathOrId); + } const envPairs: Array> = []; // The legacy key-value pair format that is still supported @@ -33,7 +116,7 @@ export async function parseEnvsData(path: string) { // CLI doesnt support bulk environments export // Hence we check for this case and throw an error if it matches the format if (HoppBulkEnvExportObjectResult.success) { - throw error({ code: "BULK_ENV_FILE", path, data: error }); + throw error({ code: "BULK_ENV_FILE", path: pathOrId, data: error }); } // Checks if the environment file is of the correct format @@ -42,7 +125,7 @@ export async function parseEnvsData(path: string) { !HoppEnvKeyPairResult.success && HoppEnvExportObjectResult.type === "err" ) { - throw error({ code: "MALFORMED_ENV_FILE", path, data: error }); + throw error({ code: "MALFORMED_ENV_FILE", path: pathOrId, data: error }); } if (HoppEnvKeyPairResult.success) { diff --git a/packages/hoppscotch-cli/src/types/commands.ts b/packages/hoppscotch-cli/src/types/commands.ts index 0c8eab855..eb7c8e8ad 100644 --- a/packages/hoppscotch-cli/src/types/commands.ts +++ b/packages/hoppscotch-cli/src/types/commands.ts @@ -1,6 +1,8 @@ export type TestCmdOptions = { env: string | undefined; delay: string | undefined; + token: string | undefined; + server: string | undefined; }; export type HOPP_ENV_FILE_EXT = "json"; diff --git a/packages/hoppscotch-cli/src/types/errors.ts b/packages/hoppscotch-cli/src/types/errors.ts index 0e75b7447..db2a9667c 100644 --- a/packages/hoppscotch-cli/src/types/errors.ts +++ b/packages/hoppscotch-cli/src/types/errors.ts @@ -26,6 +26,9 @@ type HoppErrors = { MALFORMED_ENV_FILE: HoppErrorPath & HoppErrorData; BULK_ENV_FILE: HoppErrorPath & HoppErrorData; INVALID_FILE_TYPE: HoppErrorData; + TOKEN_EXPIRED: HoppErrorData; + TOKEN_INVALID: HoppErrorData; + INVALID_ID: HoppErrorData; }; export type HoppErrorCode = keyof HoppErrors; diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts index 516f720e2..c363f71eb 100644 --- a/packages/hoppscotch-cli/src/utils/mutators.ts +++ b/packages/hoppscotch-cli/src/utils/mutators.ts @@ -1,12 +1,34 @@ -import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; +import { + CollectionSchemaVersion, + HoppCollection, + HoppRESTRequest, +} from "@hoppscotch/data"; import fs from "fs/promises"; import { entityReference } from "verzod"; import { z } from "zod"; +import axios, { AxiosError } from "axios"; import { error } from "../types/errors"; import { FormDataEntry } from "../types/request"; import { isHoppErrnoException } from "./checks"; +interface WorkspaceCollection { + id: string; + data: string | null; + title: string; + parentID: string | null; + folders: WorkspaceCollection[]; + requests: WorkspaceRequest[]; +} + +interface WorkspaceRequest { + id: string; + collectionID: string; + teamID: string; + title: string; + request: string; +} + const getValidRequests = ( collections: HoppCollection[], collectionFilePath: string @@ -42,6 +64,50 @@ const getValidRequests = ( }); }; +// Helper functions to transform workspace collection data to the `HoppCollection` format +const transformWorkspaceRequests = (requests: WorkspaceRequest[]) => + requests.map(({ request }) => JSON.parse(request)); + +const transformChildCollections = ( + childCollections: WorkspaceCollection[] +): HoppCollection[] => { + return childCollections.map(({ id, title, data, folders, requests }) => { + const parsedData = data ? JSON.parse(data) : {}; + const { auth = { authType: "inherit", authActive: false }, headers = [] } = + parsedData; + + return { + v: CollectionSchemaVersion, + id, + name: title, + folders: transformChildCollections(folders), + requests: transformWorkspaceRequests(requests), + auth, + headers, + }; + }); +}; + +const transformWorkspaceCollection = ( + collection: WorkspaceCollection +): HoppCollection => { + const { id, title, data, requests, folders } = collection; + + const parsedData = data ? JSON.parse(data) : {}; + const { auth = { authType: "inherit", authActive: false }, headers = [] } = + parsedData; + + return { + v: CollectionSchemaVersion, + id, + name: title, + folders: transformChildCollections(folders), + requests: transformWorkspaceRequests(requests), + auth, + headers, + }; +}; + /** * Parses array of FormDataEntry to FormData. * @param values Array of FormDataEntry. @@ -92,16 +158,64 @@ export async function readJsonFile(path: string): Promise { /** * 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, + * the parsed collectiona array + * @param pathOrId Collection json file path + * @param [accessToken] Personal access token to fetch workspace environments + * @param [serverUrl] server URL for SH instance + * @returns For successful parsing we get array of HoppCollection */ export async function parseCollectionData( - path: string + pathOrId: string, + accessToken?: string, + serverUrl?: string ): Promise { - let contents = await readJsonFile(path); + let contents = null; + let fileExistsInPath = null; - const maybeArrayOfCollections: unknown[] = Array.isArray(contents) + try { + await fs.access(pathOrId); + fileExistsInPath = true; + } catch (e) { + fileExistsInPath = false; + } + + if (accessToken && !fileExistsInPath) { + try { + const hostname = serverUrl || "https://api.hoppscotch.io"; + const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/collection/${pathOrId}`; + + const { data }: { data: WorkspaceCollection } = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + contents = transformWorkspaceCollection(data); + } catch (err) { + const errReason = ( + err as AxiosError<{ + reason?: any; + message: string; + error: string; + statusCode: number; + }> + ).response?.data?.reason; + + if (errReason) { + throw error({ + code: errReason, + data: pathOrId, + }); + } + } + } + + // Fallback to reading from file if contents are not available + if (contents === null) { + contents = await readJsonFile(pathOrId); + } + + const maybeArrayOfCollections: HoppCollection[] = Array.isArray(contents) ? contents : [contents]; @@ -110,12 +224,14 @@ export async function parseCollectionData( .safeParse(maybeArrayOfCollections); if (!collectionSchemaParsedResult.success) { + console.error(`Error is `, collectionSchemaParsedResult.error); + throw error({ code: "MALFORMED_COLLECTION", - path, + path: pathOrId, data: "Please check the collection data.", }); } - return getValidRequests(collectionSchemaParsedResult.data, path); + return getValidRequests(collectionSchemaParsedResult.data, pathOrId); }