feat(cli): access team workspace collections and environments (#4095)

Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
James George
2024-06-27 05:41:29 -07:00
committed by GitHub
parent fa2f73ee40
commit a9afb17dc0
48 changed files with 17569 additions and 13402 deletions

View File

@@ -1,18 +1,36 @@
import {
HoppRESTHeader,
Environment,
parseTemplateStringE,
HoppCollection,
HoppRESTHeader,
HoppRESTParam,
parseTemplateStringE,
} from "@hoppscotch/data";
import axios, { AxiosError } from "axios";
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";
import { pipe } from "fp-ts/function";
import * as S from "fp-ts/string";
import fs from "fs/promises";
import { round } from "lodash-es";
import { error } from "../types/errors";
import { DEFAULT_DURATION_PRECISION } from "./constants";
import { readJsonFile } from "./mutators";
import {
WorkspaceCollection,
WorkspaceEnvironment,
transformWorkspaceCollections,
transformWorkspaceEnvironment,
} from "./workspace-access";
type GetResourceContentsParams = {
pathOrId: string;
accessToken?: string;
serverUrl?: string;
resourceType: "collection" | "environment";
};
/**
* Generates template string (status + statusText) with specific color unicodes
@@ -134,3 +152,104 @@ export const roundDuration = (
duration: number,
precision: number = DEFAULT_DURATION_PRECISION
) => round(duration, precision);
/**
* Retrieves the contents of a resource (collection or environment) from a local file (export) or a remote server (workspaces).
*
* @param {GetResourceContentsParams} params - The parameters for retrieving resource contents.
* @param {string} params.pathOrId - The path to the local file or the ID for remote retrieval.
* @param {string} [params.accessToken] - The access token for authorizing remote retrieval.
* @param {string} [params.serverUrl] - The SH instance server URL for remote retrieval. Defaults to the cloud instance.
* @param {"collection" | "environment"} params.resourceType - The type of the resource to retrieve.
* @returns {Promise<unknown>} A promise that resolves to the contents of the resource.
* @throws Will throw an error if the content type of the fetched resource is not `application/json`,
* if there is an issue with the access token, if the server connection is refused,
* or if the server URL is invalid.
*/
export const getResourceContents = async (
params: GetResourceContentsParams
): Promise<unknown> => {
const { pathOrId, accessToken, serverUrl, resourceType } = params;
let contents: unknown | null = null;
let fileExistsInPath = false;
try {
await fs.access(pathOrId);
fileExistsInPath = true;
} catch (e) {
fileExistsInPath = false;
}
if (accessToken && !fileExistsInPath) {
const resolvedServerUrl = serverUrl || "https://api.hoppscotch.io";
try {
const separator = resolvedServerUrl.endsWith("/") ? "" : "/";
const resourcePath =
resourceType === "collection" ? "collection" : "environment";
const url = `${resolvedServerUrl}${separator}v1/access-tokens/${resourcePath}/${pathOrId}`;
const { data, headers } = await axios.get(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!headers["content-type"].includes("application/json")) {
throw new AxiosError("INVALID_CONTENT_TYPE");
}
contents =
resourceType === "collection"
? transformWorkspaceCollections([data] as WorkspaceCollection[])[0]
: transformWorkspaceEnvironment(data as WorkspaceEnvironment);
} catch (err) {
if (err instanceof AxiosError) {
const axiosErr: AxiosError<{
reason?: "TOKEN_EXPIRED" | "TOKEN_INVALID" | "INVALID_ID";
message: string;
statusCode: number;
}> = err;
const errReason = axiosErr.response?.data?.reason;
if (errReason) {
throw error({
code: errReason,
data: ["TOKEN_EXPIRED", "TOKEN_INVALID"].includes(errReason)
? accessToken
: pathOrId,
});
}
if (axiosErr.code === "ECONNREFUSED") {
throw error({
code: "SERVER_CONNECTION_REFUSED",
data: resolvedServerUrl,
});
}
if (
axiosErr.message === "INVALID_CONTENT_TYPE" ||
axiosErr.code === "ERR_INVALID_URL" ||
axiosErr.code === "ENOTFOUND" ||
axiosErr.code === "ERR_BAD_REQUEST" ||
axiosErr.response?.status === 404
) {
throw error({ code: "INVALID_SERVER_URL", data: resolvedServerUrl });
}
} else {
throw error({ code: "UNKNOWN_ERROR", data: err });
}
}
}
// Fallback to reading from file if contents are not available
if (contents === null) {
contents = await readJsonFile(pathOrId, fileExistsInPath);
}
return contents;
};

View File

@@ -1,11 +1,13 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import fs from "fs/promises";
import { entityReference } from "verzod";
import { z } from "zod";
import { TestCmdCollectionOptions } from "../types/commands";
import { error } from "../types/errors";
import { FormDataEntry } from "../types/request";
import { isHoppErrnoException } from "./checks";
import { getResourceContents } from "./getters";
const getValidRequests = (
collections: HoppCollection[],
@@ -72,15 +74,26 @@ export const parseErrorMessage = (e: unknown) => {
return msg.replace(/\n+$|\s{2,}/g, "").trim();
};
export async function readJsonFile(path: string): Promise<unknown> {
/**
* Reads a JSON file from the specified path and returns the parsed content.
*
* @param {string} path - The path to the JSON file.
* @param {boolean} fileExistsInPath - Indicates whether the file exists in the specified path.
* @returns {Promise<unknown>} A Promise that resolves to the parsed JSON contents.
* @throws {Error} If the file path does not end with `.json`.
* @throws {Error} If the file does not exist in the specified path.
* @throws {Error} If an unknown error occurs while reading or parsing the file.
*/
export async function readJsonFile(
path: string,
fileExistsInPath: boolean
): Promise<unknown> {
if (!path.endsWith(".json")) {
throw error({ code: "INVALID_FILE_TYPE", data: path });
}
try {
await fs.access(path);
} catch (e) {
throw error({ code: "FILE_NOT_FOUND", path: path });
if (!fileExistsInPath) {
throw error({ code: "FILE_NOT_FOUND", path });
}
try {
@@ -91,15 +104,27 @@ export async function readJsonFile(path: string): Promise<unknown> {
}
/**
* 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,
* Parses collection data from a given path or ID and returns the data conforming to the latest version of the `HoppCollection` schema.
*
* @param pathOrId Collection JSON file path/ID from a workspace.
* @param {TestCmdCollectionOptions} options Supplied values for CLI flags.
* @param {string} [options.token] Personal access token to fetch workspace environments.
* @param {string} [options.server] server URL for SH instance.
* @returns {Promise<HoppCollection[]>} A promise that resolves to an array of HoppCollection objects.
* @throws Throws an error if the collection data is malformed.
*/
export async function parseCollectionData(
path: string
pathOrId: string,
options: TestCmdCollectionOptions
): Promise<HoppCollection[]> {
let contents = await readJsonFile(path);
const { token: accessToken, server: serverUrl } = options;
const contents = await getResourceContents({
pathOrId,
accessToken,
serverUrl,
resourceType: "collection",
});
const maybeArrayOfCollections: unknown[] = Array.isArray(contents)
? contents
@@ -112,10 +137,10 @@ export async function parseCollectionData(
if (!collectionSchemaParsedResult.success) {
throw error({
code: "MALFORMED_COLLECTION",
path,
path: pathOrId,
data: "Please check the collection data.",
});
}
return getValidRequests(collectionSchemaParsedResult.data, path);
return getValidRequests(collectionSchemaParsedResult.data, pathOrId);
}

View File

@@ -0,0 +1,101 @@
import {
CollectionSchemaVersion,
Environment,
EnvironmentSchemaVersion,
HoppCollection,
HoppRESTRequest,
} from "@hoppscotch/data";
import { HoppEnvPair } from "../types/request";
export interface WorkspaceEnvironment {
id: string;
teamID: string;
name: string;
variables: HoppEnvPair[];
}
export 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;
}
/**
* Transforms the incoming list of workspace requests by applying `JSON.parse` to the `request` field.
*
* @param {WorkspaceRequest[]} requests - An array of workspace request objects to be transformed.
* @returns {HoppRESTRequest[]} The transformed array of requests conforming to the `HoppRESTRequest` type.
*/
const transformWorkspaceRequests = (
requests: WorkspaceRequest[]
): HoppRESTRequest[] => requests.map(({ request }) => JSON.parse(request));
/**
* Transforms workspace environment data to the `HoppEnvironment` format.
*
* @param {WorkspaceEnvironment} workspaceEnvironment - The workspace environment object to transform.
* @returns {Environment} The transformed environment object conforming to the `Environment` type.
*/
export 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,
};
};
/**
* Transforms workspace collection data to the `HoppCollection` format.
*
* @param {WorkspaceCollection[]} collections - An array of workspace collection objects to be transformed.
* @returns {HoppCollection[]} The transformed array of collections conforming to the `HoppCollection` type.
*/
export const transformWorkspaceCollections = (
collections: WorkspaceCollection[]
): HoppCollection[] => {
return collections.map((collection) => {
const { id, title, data, requests, folders } = collection;
const parsedData = data ? JSON.parse(data) : {};
const { auth = { authType: "inherit", authActive: true }, headers = [] } =
parsedData;
return {
v: CollectionSchemaVersion,
id,
name: title,
folders: transformWorkspaceCollections(folders),
requests: transformWorkspaceRequests(requests),
auth,
headers,
};
});
};