Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduces ability to export single environment variables and allow CLI to accept the export format used by the app #3380

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/hoppscotch-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hoppscotch/cli",
"version": "0.3.2",
"version": "0.3.3",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"main": "dist/index.js",
Expand Down Expand Up @@ -55,6 +55,7 @@
"qs": "^6.10.3",
"ts-jest": "^27.1.4",
"tsup": "^5.12.7",
"typescript": "^4.6.4"
"typescript": "^4.6.4",
"zod": "^3.22.2"
JoelJacobStephen marked this conversation as resolved.
Show resolved Hide resolved
}
}
7 changes: 6 additions & 1 deletion packages/hoppscotch-cli/src/handlers/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
ERROR_MSG = `Unavailable command: ${error.command}`;
break;
case "MALFORMED_ENV_FILE":
ERROR_MSG = `The environment file is not of the correct format.`;
break;
case "BULK_ENV_FILE":
ERROR_MSG = `CLI doesn't support bulk environments export.`;
break;
case "MALFORMED_COLLECTION":
ERROR_MSG = `${error.path}\n${parseErrorData(error.data)}`;
break;
Expand Down Expand Up @@ -82,4 +87,4 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
if (!S.isEmpty(ERROR_MSG)) {
console.error(ERROR_CODE, ERROR_MSG);
}
};
};
42 changes: 30 additions & 12 deletions packages/hoppscotch-cli/src/options/test/env.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
import { error } from "../../types/errors";
import { HoppEnvs, HoppEnvPair } from "../../types/request";
import {
HoppEnvs,
HoppEnvPair,
HoppEnvKeyPairObject,
HoppEnvExportObject,
HoppBulkEnvExportObject,
} from "../../types/request";
import { readJsonFile } from "../../utils/mutators";

/**
* Parses env json file for given path and validates the parsed env json object.
* @param path Path of env.json file to be parsed.
* @returns For successful parsing we get HoppEnvs object.
*/
export async function parseEnvsData(path: string) {
const contents = await readJsonFile(path)
const contents = await readJsonFile(path);
const envPairs: Array<HoppEnvPair> = [];
const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents);
const HoppEnvExportObjectResult = HoppEnvExportObject.safeParse(contents);
const HoppBulkEnvExportObjectResult =
HoppBulkEnvExportObject.safeParse(contents);

if(!(contents && typeof contents === "object" && !Array.isArray(contents))) {
throw error({ code: "MALFORMED_ENV_FILE", path, data: null })
// 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 });
}

const envPairs: Array<HoppEnvPair> = []
// Checks if the environment file is of the correct format.
// If it doesnt match either of them, we throw an error.
if (!(HoppEnvKeyPairResult.success || HoppEnvExportObjectResult.success)) {
throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
JoelJacobStephen marked this conversation as resolved.
Show resolved Hide resolved
}

for( const [key,value] of Object.entries(contents)) {
if(typeof value !== "string") {
throw error({ code: "MALFORMED_ENV_FILE", path, data: {value: value} })
if (HoppEnvKeyPairResult.success) {
for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) {
envPairs.push({ key, value });
}

envPairs.push({key, value})
} else if (HoppEnvExportObjectResult.success) {
const { key, value } = HoppEnvExportObjectResult.data.variables[0];
envPairs.push({ key, value });
}
return <HoppEnvs>{ global: [], selected: envPairs }

return <HoppEnvs>{ global: [], selected: envPairs };
}
1 change: 1 addition & 0 deletions packages/hoppscotch-cli/src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type HoppErrors = {
REQUEST_ERROR: HoppErrorData;
INVALID_ARGUMENT: HoppErrorData;
MALFORMED_ENV_FILE: HoppErrorPath & HoppErrorData;
BULK_ENV_FILE: HoppErrorPath & HoppErrorData;
INVALID_FILE_TYPE: HoppErrorData;
};

Expand Down
17 changes: 17 additions & 0 deletions packages/hoppscotch-cli/src/types/request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { TestReport } from "../interfaces/response";
import { HoppCLIError } from "./errors";
import { z } from "zod";

export type FormDataEntry = {
key: string;
Expand All @@ -9,6 +10,22 @@ export type FormDataEntry = {

export type HoppEnvPair = { key: string; value: string };

export const HoppEnvKeyPairObject = z.record(z.string(), z.string());

// Shape of the single environment export object that is exported from the app.
export const HoppEnvExportObject = z.object({
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
});

// Shape of the bulk environment export object that is exported from the app.
export const HoppBulkEnvExportObject = z.array(HoppEnvExportObject);

export type HoppEnvs = {
global: HoppEnvPair[];
selected: HoppEnvPair[];
Expand Down
1 change: 1 addition & 0 deletions packages/hoppscotch-common/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@
"disconnected_from": "Disconnected from {name}",
"docs_generated": "Documentation generated",
"download_started": "Download started",
"download_failed": "Download failed",
"enabled": "Enabled",
"file_imported": "File imported",
"finished_in": "Finished in {duration} ms",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
role="menu"
@keyup.e="edit!.$el.click()"
@keyup.d="duplicate!.$el.click()"
@keyup.j="exportAsJsonEl!.$el.click()"
@keyup.delete="
!(environmentIndex === 'Global')
? deleteAction!.$el.click()
Expand Down Expand Up @@ -77,6 +78,18 @@
}
"
/>
<HoppSmartItem
ref="exportAsJsonEl"
:icon="IconEdit"
:label="`${t('export.as_json')}`"
:shortcut="['J']"
@click="
() => {
exportEnvironmentAsJSON()
hide()
}
"
/>
<HoppSmartItem
v-if="environmentIndex !== 'Global'"
ref="deleteAction"
Expand Down Expand Up @@ -121,6 +134,7 @@ import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment"

const t = useI18n()
const toast = useToast()
Expand All @@ -136,10 +150,18 @@ const emit = defineEmits<{

const confirmRemove = ref(false)

const exportEnvironmentAsJSON = () => {
const { environment, environmentIndex } = props
exportAsJSON(environment, environmentIndex)
? toast.success(t("state.download_started"))
: toast.error(t("state.download_failed"))
}

const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof HoppSmartItem>()
const duplicate = ref<typeof HoppSmartItem>()
const exportAsJsonEl = ref<typeof HoppSmartItem>()
const deleteAction = ref<typeof HoppSmartItem>()

const removeEnvironment = () => {
Expand Down
JoelJacobStephen marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
role="menu"
@keyup.e="edit!.$el.click()"
@keyup.d="duplicate!.$el.click()"
@keyup.j="exportAsJsonEl!.$el.click()"
@keyup.delete="deleteAction!.$el.click()"
@keyup.escape="options!.tippy().hide()"
>
Expand All @@ -54,6 +55,7 @@
}
"
/>

<HoppSmartItem
ref="duplicate"
:icon="IconCopy"
Expand All @@ -66,6 +68,18 @@
}
"
/>
<HoppSmartItem
ref="exportAsJsonEl"
:icon="IconEdit"
:label="`${t('export.as_json')}`"
:shortcut="['J']"
@click="
() => {
exportEnvironmentAsJSON()
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
Expand Down Expand Up @@ -109,6 +123,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
import IconMoreVertical from "~icons/lucide/more-vertical"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment"

const t = useI18n()
const toast = useToast()
Expand All @@ -124,11 +139,17 @@ const emit = defineEmits<{

const confirmRemove = ref(false)

const exportEnvironmentAsJSON = () =>
exportAsJSON(props.environment)
? toast.success(t("state.download_started"))
: toast.error(t("state.download_failed"))

const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof HoppSmartItem>()
const duplicate = ref<typeof HoppSmartItem>()
const deleteAction = ref<typeof HoppSmartItem>()
const exportAsJsonEl = ref<typeof HoppSmartItem>()

const removeEnvironment = () => {
pipe(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Environment } from "@hoppscotch/data"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { cloneDeep } from "lodash-es"

const getEnvironmentJson = (
environmentObj: TeamEnvironment | Environment,
environmentIndex?: number | "Global" | null
) => {
const newEnvironment =
"environment" in environmentObj
? cloneDeep(environmentObj.environment)
: cloneDeep(environmentObj)

delete newEnvironment.id

const environmentId =
environmentIndex || environmentIndex === 0
? environmentIndex
: environmentObj.id

return environmentId !== null
? JSON.stringify(newEnvironment, null, 2)
: undefined
}

export const exportAsJSON = (
environmentObj: Environment | TeamEnvironment,
environmentIndex?: number | "Global" | null
): boolean => {
const dataToWrite = getEnvironmentJson(environmentObj, environmentIndex)

if (!dataToWrite) return false

const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url

// Extracts the path from url, removes fragment identifier and query parameters if any, appends the ".json" extension, and assigns it
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 0)
return true
}
1 change: 1 addition & 0 deletions packages/hoppscotch-sh-admin/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ declare module '@vue/runtime-core' {
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
Expand Down