diff --git a/packages/hoppscotch-app/components/smart/EnvInput.vue b/packages/hoppscotch-app/components/smart/EnvInput.vue index 2835531c0..1062780fa 100644 --- a/packages/hoppscotch-app/components/smart/EnvInput.vue +++ b/packages/hoppscotch-app/components/smart/EnvInput.vue @@ -35,10 +35,13 @@ import { EditorState, Extension } from "@codemirror/state" import clone from "lodash/clone" import { tooltips } from "@codemirror/tooltip" import { history, historyKeymap } from "@codemirror/history" +import { HoppRESTVar } from "@hoppscotch/data" import { inputTheme } from "~/helpers/editor/themes/baseTheme" import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment" import { useReadonlyStream } from "~/helpers/utils/composables" import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments" +import { HoppReactiveVarPlugin } from "~/helpers/editor/extensions/HoppVariable" +import { restVars$ } from "~/newstore/RESTSession" const props = withDefaults( defineProps<{ @@ -46,6 +49,7 @@ const props = withDefaults( placeholder: string styles: string envs: { key: string; value: string; source: string }[] | null + vars: { key: string; value: string }[] | null focus: boolean readonly: boolean }>(), @@ -54,6 +58,7 @@ const props = withDefaults( placeholder: "", styles: "", envs: null, + vars: null, focus: false, readonly: false, } @@ -109,6 +114,7 @@ let pastedValue: string | null = null const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref< AggregateEnvironment[] > +const aggregateVars = useReadonlyStream(restVars$, []) as Ref const envVars = computed(() => props.envs @@ -120,7 +126,17 @@ const envVars = computed(() => : aggregateEnvs.value ) +const varVars = computed(() => + props.vars + ? props.vars.map((x) => ({ + key: x.key, + value: x.value, + })) + : aggregateVars.value +) + const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view) +const varTooltipPlugin = new HoppReactiveVarPlugin(varVars, view) const initView = (el: any) => { const extensions: Extension = [ @@ -146,6 +162,7 @@ const initView = (el: any) => { position: "absolute", }), envTooltipPlugin, + varTooltipPlugin, placeholderExt(props.placeholder), EditorView.domEventHandlers({ paste(ev) { diff --git a/packages/hoppscotch-app/helpers/editor/extensions/HoppVariable.ts b/packages/hoppscotch-app/helpers/editor/extensions/HoppVariable.ts new file mode 100644 index 000000000..401f00a98 --- /dev/null +++ b/packages/hoppscotch-app/helpers/editor/extensions/HoppVariable.ts @@ -0,0 +1,180 @@ +import { watch, Ref } from "@nuxtjs/composition-api" +import { Compartment } from "@codemirror/state" +import { hoverTooltip } from "@codemirror/tooltip" +import { + Decoration, + EditorView, + MatchDecorator, + ViewPlugin, +} from "@codemirror/view" +import * as E from "fp-ts/Either" +import { HoppRESTVar, parseTemplateStringE } from "@hoppscotch/data" + +const HOPP_ENVIRONMENT_REGEX = /({{\w+}})/g + +const HOPP_ENV_HIGHLIGHT = + "cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight" +const HOPP_ENV_HIGHLIGHT_FOUND = + "bg-accentDark text-accentContrast hover:bg-accent" +const HOPP_ENV_HIGHLIGHT_NOT_FOUND = + "bg-red-500 text-accentContrast hover:bg-red-600" + +const cursorTooltipField = (aggregateEnvs: HoppRESTVar[]) => + hoverTooltip( + (view, pos, side) => { + const { from, to, text } = view.state.doc.lineAt(pos) + + // TODO: When Codemirror 6 allows this to work (not make the + // popups appear half of the time) use this implementation + // const wordSelection = view.state.wordAt(pos) + // if (!wordSelection) return null + // const word = view.state.doc.sliceString( + // wordSelection.from - 2, + // wordSelection.to + 2 + // ) + // if (!HOPP_ENVIRONMENT_REGEX.test(word)) return null + + // Tracking the start and the end of the words + let start = pos + let end = pos + + while (start > from && /\w/.test(text[start - from - 1])) start-- + while (end < to && /\w/.test(text[end - from])) end++ + + if ( + (start === pos && side < 0) || + (end === pos && side > 0) || + !HOPP_ENVIRONMENT_REGEX.test( + text.slice(start - from - 2, end - from + 2) + ) + ) + return null + + const envValue = + aggregateEnvs.find( + (env) => env.key === text.slice(start - from, end - from) + // env.key === word.slice(wordSelection.from + 2, wordSelection.to - 2) + )?.value ?? "not found" + + const result = parseTemplateStringE(envValue, aggregateEnvs) + + const finalEnv = E.isLeft(result) ? "error" : result.right + + return { + pos: start, + end: to, + above: true, + arrow: true, + create() { + const dom = document.createElement("span") + const xmp = document.createElement("xmp") + xmp.textContent = finalEnv + dom.appendChild(xmp) + dom.className = "tooltip-theme" + return { dom } + }, + } + }, + // HACK: This is a hack to fix hover tooltip not coming half of the time + // https://github.com/codemirror/tooltip/blob/765c463fc1d5afcc3ec93cee47d72606bed27e1d/src/tooltip.ts#L622 + // Still doesn't fix the not showing up some of the time issue, but this is atleast more consistent + { hoverTime: 1 } as any + ) + +function checkEnv(env: string, aggregateEnvs: HoppRESTVar[]) { + const className = aggregateEnvs.find( + (k: { key: string }) => k.key === env.slice(2, -2) + ) + ? HOPP_ENV_HIGHLIGHT_FOUND + : HOPP_ENV_HIGHLIGHT_NOT_FOUND + + return Decoration.mark({ + class: `${HOPP_ENV_HIGHLIGHT} ${className}`, + }) +} + +const getMatchDecorator = (aggregateEnvs: HoppRESTVar[]) => + new MatchDecorator({ + regexp: HOPP_ENVIRONMENT_REGEX, + decoration: (m) => checkEnv(m[0], aggregateEnvs), + }) + +export const environmentHighlightStyle = (aggregateEnvs: HoppRESTVar[]) => { + const decorator = getMatchDecorator(aggregateEnvs) + + return ViewPlugin.define( + (view) => ({ + decorations: decorator.createDeco(view), + update(u) { + this.decorations = decorator.updateDeco(u, this.decorations) + }, + }), + { + decorations: (v) => v.decorations, + } + ) +} + +// export class HoppEnvironmentPlugin { +// private compartment = new Compartment() +// +// private envs: AggregateEnvironment[] = [] +// +// constructor( +// subscribeToStream: StreamSubscriberFunc, +// private editorView: Ref +// ) { +// this.envs = getAggregateEnvs() +// +// subscribeToStream(aggregateEnvs$, (envs) => { +// this.envs = envs +// +// this.editorView.value?.dispatch({ +// effects: this.compartment.reconfigure([ +// cursorTooltipField(this.envs), +// environmentHighlightStyle(this.envs), +// ]), +// }) +// }) +// } +// +// get extension() { +// return this.compartment.of([ +// cursorTooltipField(this.envs), +// environmentHighlightStyle(this.envs), +// ]) +// } +// } + +export class HoppReactiveVarPlugin { + private compartment = new Compartment() + + private envs: HoppRESTVar[] = [] + + constructor( + envsRef: Ref, + private editorView: Ref + ) { + watch( + envsRef, + (envs) => { + this.envs = envs + + this.editorView.value?.dispatch({ + effects: this.compartment.reconfigure([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]), + }) + }, + { immediate: true } + ) + } + + get extension() { + return this.compartment.of([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]) + } +} diff --git a/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts index fae74280e..c90c012b0 100644 --- a/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts @@ -14,6 +14,7 @@ import { HoppRESTHeader, HoppRESTParam, } from "@hoppscotch/data" +import { parseTemplateStringV } from "@hoppscotch/data/src/pathVariables" import { arrayFlatMap, arraySort } from "../functional/array" import { toFormData } from "../functional/formData" import { tupleToRecord } from "../functional/record" @@ -29,6 +30,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest { effectiveFinalHeaders: { key: string; value: string }[] effectiveFinalParams: { key: string; value: string }[] effectiveFinalBody: FormData | string | null + effectiveFinalVars: { key: string; value: string }[] } /** @@ -298,15 +300,21 @@ export function getEffectiveRESTRequest( value: parseTemplateString(x.value, envVariables), })) ) + const effectiveFinalVars = request.vars const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables) return { ...request, - effectiveFinalURL: parseTemplateString(request.endpoint, envVariables), + effectiveFinalURL: parseTemplateStringV( + request.endpoint, + envVariables, + request.vars + ), effectiveFinalHeaders, effectiveFinalParams, effectiveFinalBody, + effectiveFinalVars, } } diff --git a/packages/hoppscotch-app/newstore/RESTSession.ts b/packages/hoppscotch-app/newstore/RESTSession.ts index 5d00070de..7a9e7cfe4 100644 --- a/packages/hoppscotch-app/newstore/RESTSession.ts +++ b/packages/hoppscotch-app/newstore/RESTSession.ts @@ -30,12 +30,7 @@ export const getDefaultRESTRequest = (): HoppRESTRequest => ({ endpoint: "https://echo.hoppscotch.io", name: "Untitled request", params: [], - vars: [ - { - key: "amount", - value: "23", - }, - ], + vars: [], headers: [], method: "GET", auth: { diff --git a/packages/hoppscotch-data/src/environment.ts b/packages/hoppscotch-data/src/environment.ts index 415977344..1a6e58b36 100644 --- a/packages/hoppscotch-data/src/environment.ts +++ b/packages/hoppscotch-data/src/environment.ts @@ -9,7 +9,14 @@ export type Environment = { }[] } +export type Variables = { + key: string + value: string + }[] + + const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<>" +const REGEX_PATHVAR = /{{([^>]*)}}/g // "{{myVariable}}" /** * How much times can we expand environment variables @@ -59,9 +66,9 @@ export const parseBodyEnvVariables = ( export function parseTemplateStringE( str: string, - variables: Environment["variables"] + variables: Environment["variables"], ) { - if (!variables || !str) { + if (!variables || !str ) { return E.right(str) } diff --git a/packages/hoppscotch-data/src/pathVariables.ts b/packages/hoppscotch-data/src/pathVariables.ts new file mode 100644 index 000000000..44892680d --- /dev/null +++ b/packages/hoppscotch-data/src/pathVariables.ts @@ -0,0 +1,76 @@ +import { pipe } from "fp-ts/function" +import * as E from "fp-ts/Either" +import {parseTemplateStringE} from "./environment"; + +export type Environment = { + name: string + variables: { + key: string + value: string + }[] +} + +export type Variables = { + key: string + value: string +}[] + +const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<>" +const REGEX_PATHVAR = /{{([^>]*)}}/g // "{{myVariable}}" + +/** + * How much times can we expand environment variables + */ +const ENV_MAX_EXPAND_LIMIT = 10 + +/** + * Error state when there is a suspected loop while + * recursively expanding variables + */ +const ENV_EXPAND_LOOP = "ENV_EXPAND_LOOP" as const + +export function parseTemplateStringEV( + str: string, + variables: Environment["variables"], + pathVariables: Variables +) { + if (!variables || !str || !pathVariables) { + return E.right(str) + } + + let result = str + let depth = 0 + + while (result.match(REGEX_ENV_VAR) != null && depth <= ENV_MAX_EXPAND_LIMIT) { + result = decodeURI(encodeURI(result)).replace( + REGEX_ENV_VAR, + (_, p1) => variables.find((x) => x.key === p1)?.value || "" + ) + depth++ + } + + while (result.match(REGEX_PATHVAR) != null && depth <= ENV_MAX_EXPAND_LIMIT) { + result = decodeURI(encodeURI(result)).replace( + REGEX_PATHVAR, + (_, p1) => pathVariables.find((x) => x.key === p1)?.value || "" + ) + } + + return depth > ENV_MAX_EXPAND_LIMIT + ? E.left(ENV_EXPAND_LOOP) + : E.right(result) +} + +/** + * @deprecated Use `parseTemplateStringE` instead + */ +export const parseTemplateStringV = ( + str: string, + variables: Environment["variables"], + pathVariables: Variables +) => + pipe( + parseTemplateStringEV(str, variables, pathVariables), + E.getOrElse(() => str) + ) +