feat(cli): access team workspace collections and environments (#4095)
Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
101
packages/hoppscotch-cli/src/utils/workspace-access.ts
Normal file
101
packages/hoppscotch-cli/src/utils/workspace-access.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user