Browse Source

Merge branch 'fix/urlencoded'

liyasthomas 3 years ago
parent
commit
10a54d14c2

+ 3 - 0
packages/hoppscotch-app/components/http/Body.vue

@@ -50,6 +50,9 @@
       </span>
     </div>
     <HttpBodyParameters v-if="contentType === 'multipart/form-data'" />
+    <HttpURLEncodedParams
+      v-else-if="contentType === 'application/x-www-form-urlencoded'"
+    />
     <HttpRawBody v-else-if="contentType !== null" :content-type="contentType" />
     <div
       v-if="contentType == null"

+ 110 - 36
packages/hoppscotch-app/components/http/BodyParameters.vue

@@ -29,7 +29,7 @@
       </div>
     </div>
     <div
-      v-for="(param, index) in bodyParams"
+      v-for="(param, index) in workingParams"
       :key="`param-${index}`"
       class="flex border-b divide-x divide-dividerLight border-dividerLight"
     >
@@ -159,35 +159,128 @@
 </template>
 
 <script setup lang="ts">
-import { onMounted, Ref, watch } from "@nuxtjs/composition-api"
+import { ref, Ref, watch } from "@nuxtjs/composition-api"
 import { FormDataKeyValue } from "@hoppscotch/data"
-import { pluckRef } from "~/helpers/utils/composables"
-import {
-  addFormDataEntry,
-  deleteAllFormDataEntries,
-  deleteFormDataEntry,
-  updateFormDataEntry,
-  useRESTRequestBody,
-} from "~/newstore/RESTSession"
+import isEqual from "lodash/isEqual"
+import { clone } from "lodash"
+import { pluckRef, useI18n, useToast } from "~/helpers/utils/composables"
+import { useRESTRequestBody } from "~/newstore/RESTSession"
+
+const t = useI18n()
+
+const toast = useToast()
+
+const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
 
 const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body") as Ref<
   FormDataKeyValue[]
 >
 
+// The UI representation of the parameters list (has the empty end param)
+const workingParams = ref<FormDataKeyValue[]>([
+  {
+    key: "",
+    value: "",
+    active: true,
+    isFile: false,
+  },
+])
+
+// Rule: Working Params always have last element is always an empty param
+watch(workingParams, (paramsList) => {
+  if (paramsList.length > 0 && paramsList[paramsList.length - 1].key !== "") {
+    workingParams.value.push({
+      key: "",
+      value: "",
+      active: true,
+      isFile: false,
+    })
+  }
+})
+
+// Sync logic between params and working params
+watch(
+  bodyParams,
+  (newParamsList) => {
+    // Sync should overwrite working params
+    const filteredWorkingParams = workingParams.value.filter(
+      (e) => e.key !== ""
+    )
+
+    if (!isEqual(newParamsList, filteredWorkingParams)) {
+      workingParams.value = newParamsList
+    }
+  },
+  { immediate: true }
+)
+
+watch(workingParams, (newWorkingParams) => {
+  const fixedParams = newWorkingParams.filter((e) => e.key !== "")
+  if (!isEqual(bodyParams.value, fixedParams)) {
+    bodyParams.value = fixedParams
+  }
+})
+
 const addBodyParam = () => {
-  addFormDataEntry({ key: "", value: "", active: true, isFile: false })
+  workingParams.value.push({
+    key: "",
+    value: "",
+    active: true,
+    isFile: false,
+  })
 }
 
-const updateBodyParam = (index: number, entry: FormDataKeyValue) => {
-  updateFormDataEntry(index, entry)
+const updateBodyParam = (index: number, param: FormDataKeyValue) => {
+  workingParams.value = workingParams.value.map((h, i) =>
+    i === index ? param : h
+  )
 }
 
 const deleteBodyParam = (index: number) => {
-  deleteFormDataEntry(index)
+  const paramsBeforeDeletion = clone(workingParams.value)
+
+  if (
+    !(
+      paramsBeforeDeletion.length > 0 &&
+      index === paramsBeforeDeletion.length - 1
+    )
+  ) {
+    if (deletionToast.value) {
+      deletionToast.value.goAway(0)
+      deletionToast.value = null
+    }
+
+    deletionToast.value = toast.success(`${t("state.deleted")}`, {
+      action: [
+        {
+          text: `${t("action.undo")}`,
+          onClick: (_, toastObject) => {
+            workingParams.value = paramsBeforeDeletion
+            toastObject.goAway(0)
+            deletionToast.value = null
+          },
+        },
+      ],
+
+      onComplete: () => {
+        deletionToast.value = null
+      },
+    })
+  }
+
+  workingParams.value.splice(index, 1)
 }
 
 const clearContent = () => {
-  deleteAllFormDataEntries()
+  // set params list to the initial state
+  workingParams.value = [
+    {
+      key: "",
+      value: "",
+      active: true,
+      isFile: false,
+    },
+  ]
 }
 
 const setRequestAttachment = (
@@ -197,7 +290,7 @@ const setRequestAttachment = (
 ) => {
   // check if file exists or not
   if ((event.target as HTMLInputElement).files?.length === 0) {
-    updateFormDataEntry(index, {
+    updateBodyParam(index, {
       ...entry,
       isFile: false,
       value: "",
@@ -210,27 +303,8 @@ const setRequestAttachment = (
     isFile: true,
     value: Array.from((event.target as HTMLInputElement).files!),
   }
-  updateFormDataEntry(index, fileEntry)
+  updateBodyParam(index, fileEntry)
 }
-
-watch(
-  bodyParams,
-  () => {
-    if (
-      bodyParams.value.length > 0 &&
-      (bodyParams.value[bodyParams.value.length - 1].key !== "" ||
-        bodyParams.value[bodyParams.value.length - 1].value !== "")
-    )
-      addBodyParam()
-  },
-  { deep: true }
-)
-
-onMounted(() => {
-  if (!bodyParams.value?.length) {
-    addBodyParam()
-  }
-})
 </script>
 
 <style scoped lang="scss">

+ 355 - 0
packages/hoppscotch-app/components/http/URLEncodedParams.vue

@@ -0,0 +1,355 @@
+<template>
+  <div>
+    <div
+      class="sticky z-10 flex items-center justify-between flex-1 pl-4 border-b bg-primary border-dividerLight top-upperTertiaryStickyFold"
+    >
+      <label class="font-semibold text-secondaryLight">
+        {{ t("request.body") }}
+      </label>
+      <div class="flex">
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          to="https://docs.hoppscotch.io/features/body"
+          blank
+          :title="t('app.wiki')"
+          svg="help-circle"
+        />
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          :title="t('action.clear_all')"
+          svg="trash-2"
+          @click.native="clearContent()"
+        />
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          :title="t('state.bulk_mode')"
+          svg="edit"
+          :class="{ '!text-accent': bulkMode }"
+          @click.native="bulkMode = !bulkMode"
+        />
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          :title="t('add.new')"
+          svg="plus"
+          :disabled="bulkMode"
+          @click.native="addUrlEncodedParam"
+        />
+      </div>
+    </div>
+    <div v-if="bulkMode" ref="bulkEditor"></div>
+    <div v-else>
+      <div
+        v-for="(param, index) in workingUrlEncodedParams"
+        :key="`param-${index}`"
+        class="flex border-b divide-x divide-dividerLight border-dividerLight"
+      >
+        <SmartEnvInput
+          v-model="param.key"
+          :placeholder="`${t('count.parameter', { count: index + 1 })}`"
+          styles="
+            bg-transparent
+            flex
+            flex-1
+            py-1
+            px-4
+          "
+          @change="
+            updateUrlEncodedParam(index, {
+              key: $event,
+              value: param.value,
+              active: param.active,
+            })
+          "
+        />
+        <SmartEnvInput
+          v-model="param.value"
+          :placeholder="`${t('count.value', { count: index + 1 })}`"
+          styles="
+            bg-transparent
+            flex
+            flex-1
+            py-1
+            px-4
+          "
+          @change="
+            updateUrlEncodedParam(index, {
+              key: param.key,
+              value: $event,
+              active: param.active,
+            })
+          "
+        />
+        <span>
+          <ButtonSecondary
+            v-tippy="{ theme: 'tooltip' }"
+            :title="
+              param.hasOwnProperty('active')
+                ? param.active
+                  ? t('action.turn_off')
+                  : t('action.turn_on')
+                : t('action.turn_off')
+            "
+            :svg="
+              param.hasOwnProperty('active')
+                ? param.active
+                  ? 'check-circle'
+                  : 'circle'
+                : 'check-circle'
+            "
+            color="green"
+            @click.native="
+              updateUrlEncodedParam(index, {
+                key: param.key,
+                value: param.value,
+                active: !param.active,
+              })
+            "
+          />
+        </span>
+        <span>
+          <ButtonSecondary
+            v-tippy="{ theme: 'tooltip' }"
+            :title="t('action.remove')"
+            svg="trash"
+            color="red"
+            @click.native="deleteUrlEncodedParam(index)"
+          />
+        </span>
+      </div>
+      <div
+        v-if="workingUrlEncodedParams.length === 0"
+        class="flex flex-col text-secondaryLight p-4 items-center justify-center"
+      >
+        <img
+          :src="`/images/states/${$colorMode.value}/add_category.svg`"
+          loading="lazy"
+          class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
+          :alt="`${t('empty.body')}`"
+        />
+        <span class="pb-4 text-center">
+          {{ t("empty.body") }}
+        </span>
+        <ButtonSecondary
+          filled
+          :label="`${t('add.new')}`"
+          svg="plus"
+          class="mb-4"
+          @click.native="addUrlEncodedParam"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, Ref, ref, watch } from "@nuxtjs/composition-api"
+import isEqual from "lodash/isEqual"
+import clone from "lodash/clone"
+import { HoppRESTReqBody } from "@hoppscotch/data"
+import { useCodemirror } from "~/helpers/editor/codemirror"
+import { useRESTRequestBody } from "~/newstore/RESTSession"
+import { pluckRef, useI18n, useToast } from "~/helpers/utils/composables"
+import {
+  parseRawKeyValueEntries,
+  rawKeyValueEntriesToString,
+  RawKeyValueEntry,
+} from "~/helpers/rawKeyValue"
+
+const t = useI18n()
+const toast = useToast()
+
+const bulkMode = ref(false)
+const bulkUrlEncodedParams = ref("")
+const bulkEditor = ref<any | null>(null)
+
+const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
+
+useCodemirror(bulkEditor, bulkUrlEncodedParams, {
+  extendedEditorConfig: {
+    mode: "text/x-yaml",
+    placeholder: `${t("state.bulk_mode_placeholder")}`,
+  },
+  linter: null,
+  completer: null,
+  environmentHighlights: true,
+})
+
+// The functional urlEncodedParams list (the urlEncodedParams actually in the system)
+const urlEncodedParamsRaw = pluckRef(
+  useRESTRequestBody() as Ref<
+    HoppRESTReqBody & { contentType: "application/x-www-form-urlencoded" }
+  >,
+  "body"
+)
+
+const urlEncodedParams = computed<RawKeyValueEntry[]>({
+  get() {
+    return parseRawKeyValueEntries(urlEncodedParamsRaw.value)
+  },
+  set(newValue) {
+    urlEncodedParamsRaw.value = rawKeyValueEntriesToString(newValue)
+  },
+})
+
+// The UI representation of the urlEncodedParams list (has the empty end urlEncodedParam)
+const workingUrlEncodedParams = ref<RawKeyValueEntry[]>([
+  {
+    key: "",
+    value: "",
+    active: true,
+  },
+])
+
+// Rule: Working urlEncodedParams always have one empty urlEncodedParam or the last element is always an empty urlEncodedParams
+watch(workingUrlEncodedParams, (urlEncodedParamList) => {
+  if (
+    urlEncodedParamList.length > 0 &&
+    urlEncodedParamList[urlEncodedParamList.length - 1].key !== ""
+  ) {
+    workingUrlEncodedParams.value.push({
+      key: "",
+      value: "",
+      active: true,
+    })
+  }
+})
+
+// Sync logic between urlEncodedParams and working urlEncodedParams
+watch(
+  urlEncodedParams,
+  (newurlEncodedParamList) => {
+    const filteredWorkingUrlEncodedParams =
+      workingUrlEncodedParams.value.filter((e) => e.key !== "")
+
+    if (!isEqual(newurlEncodedParamList, filteredWorkingUrlEncodedParams)) {
+      workingUrlEncodedParams.value = newurlEncodedParamList
+    }
+  },
+  { immediate: true }
+)
+
+watch(workingUrlEncodedParams, (newWorkingUrlEncodedParams) => {
+  const fixedUrlEncodedParams = newWorkingUrlEncodedParams.filter(
+    (e) => e.key !== ""
+  )
+  if (!isEqual(urlEncodedParams.value, fixedUrlEncodedParams)) {
+    urlEncodedParams.value = fixedUrlEncodedParams
+  }
+})
+
+// Bulk Editor Syncing with Working urlEncodedParams
+watch(bulkUrlEncodedParams, () => {
+  try {
+    const transformation = bulkUrlEncodedParams.value
+      .split("\n")
+      .filter((x) => x.trim().length > 0 && x.includes(":"))
+      .map((item) => ({
+        key: item.substring(0, item.indexOf(":")).trimLeft().replace(/^#/, ""),
+        value: item.substring(item.indexOf(":") + 1).trimLeft(),
+        active: !item.trim().startsWith("#"),
+      }))
+
+    const filteredUrlEncodedParams = workingUrlEncodedParams.value.filter(
+      (x) => x.key !== ""
+    )
+
+    if (!isEqual(filteredUrlEncodedParams, transformation)) {
+      workingUrlEncodedParams.value = transformation
+    }
+  } catch (e) {
+    toast.error(`${t("error.something_went_wrong")}`)
+    console.error(e)
+  }
+})
+
+watch(workingUrlEncodedParams, (newurlEncodedParamList) => {
+  if (bulkMode.value) return
+
+  try {
+    const currentBulkUrlEncodedParams = bulkUrlEncodedParams.value
+      .split("\n")
+      .map((item) => ({
+        key: item.substring(0, item.indexOf(":")).trimLeft().replace(/^#/, ""),
+        value: item.substring(item.indexOf(":") + 1).trimLeft(),
+        active: !item.trim().startsWith("#"),
+      }))
+
+    const filteredUrlEncodedParams = newurlEncodedParamList.filter(
+      (x) => x.key !== ""
+    )
+
+    if (!isEqual(currentBulkUrlEncodedParams, filteredUrlEncodedParams)) {
+      bulkUrlEncodedParams.value = filteredUrlEncodedParams
+        .map((param) => {
+          return `${param.active ? "" : "#"}${param.key}: ${param.value}`
+        })
+        .join("\n")
+    }
+  } catch (e) {
+    toast.error(`${t("error.something_went_wrong")}`)
+    console.error(e)
+  }
+})
+
+const addUrlEncodedParam = () => {
+  workingUrlEncodedParams.value.push({
+    key: "",
+    value: "",
+    active: true,
+  })
+}
+
+const updateUrlEncodedParam = (index: number, param: RawKeyValueEntry) => {
+  workingUrlEncodedParams.value = workingUrlEncodedParams.value.map((p, i) =>
+    i === index ? param : p
+  )
+}
+
+const deleteUrlEncodedParam = (index: number) => {
+  const urlEncodedParamsBeforeDeletion = clone(workingUrlEncodedParams.value)
+
+  if (
+    !(
+      urlEncodedParamsBeforeDeletion.length > 0 &&
+      index === urlEncodedParamsBeforeDeletion.length - 1
+    )
+  ) {
+    if (deletionToast.value) {
+      deletionToast.value.goAway(0)
+      deletionToast.value = null
+    }
+
+    deletionToast.value = toast.success(`${t("state.deleted")}`, {
+      action: [
+        {
+          text: `${t("action.undo")}`,
+          onClick: (_, toastObject) => {
+            workingUrlEncodedParams.value = urlEncodedParamsBeforeDeletion
+            toastObject.goAway(0)
+            deletionToast.value = null
+          },
+        },
+      ],
+
+      onComplete: () => {
+        deletionToast.value = null
+      },
+    })
+  }
+
+  workingUrlEncodedParams.value.splice(index, 1)
+}
+
+const clearContent = () => {
+  // set urlEncodedParams list to the initial state
+  workingUrlEncodedParams.value = [
+    {
+      key: "",
+      value: "",
+      active: true,
+    },
+  ]
+
+  bulkUrlEncodedParams.value = ""
+}
+</script>

+ 3 - 0
packages/hoppscotch-app/helpers/functional/array.ts

@@ -39,3 +39,6 @@ export const arrayFlatMap =
   <T, U>(mapFunc: (value: T, index: number, arr: T[]) => U[]) =>
   (arr: T[]) =>
     arr.flatMap(mapFunc)
+
+export const stringArrayJoin = (separator: string) => (arr: string[]) =>
+  arr.join(separator)

+ 9 - 0
packages/hoppscotch-app/helpers/functional/debug.ts

@@ -0,0 +1,9 @@
+/**
+ * Logs the current value and returns the same value
+ * @param x The value to log
+ * @returns The parameter `x` passed to this
+ */
+export const trace = <T>(x: T) => {
+  console.log(x)
+  return x
+}

+ 9 - 0
packages/hoppscotch-app/helpers/functional/record.ts

@@ -0,0 +1,9 @@
+export const tupleToRecord = <
+  KeyType extends string | number | symbol,
+  ValueType
+>(
+  tuples: [KeyType, ValueType][]
+): Record<KeyType, ValueType> =>
+  tuples.length > 0
+    ? (Object.assign as any)(...tuples.map(([key, val]) => ({ [key]: val })))
+    : {}

+ 20 - 9
packages/hoppscotch-app/helpers/new-codegen/har.ts

@@ -1,3 +1,6 @@
+import * as RA from "fp-ts/ReadonlyArray"
+import * as S from "fp-ts/string"
+import { pipe, flow } from "fp-ts/function"
 import * as Har from "har-format"
 import { HoppRESTRequest } from "@hoppscotch/data"
 import { FieldEquals, objectFieldIncludes } from "../typeutils"
@@ -33,16 +36,24 @@ const buildHarPostParams = (
 ): Har.Param[] => {
   // URL Encoded strings have a string style of contents
   if (req.body.contentType === "application/x-www-form-urlencoded") {
-    return req.body.body
-      .split("&") // Split by separators
-      .map((keyValue) => {
-        const [key, value] = keyValue.split("=")
+    return pipe(
+      req.body.body,
+      S.split("\n"),
+      RA.map(
+        flow(
+          // Define how each lines are parsed
 
-        return {
-          name: key,
-          value,
-        }
-      })
+          S.split(":"), // Split by ":"
+          RA.map(S.trim), // Remove trailing spaces in key/value begins and ends
+          ([key, value]) => ({
+            // Convert into a proper key value definition
+            name: key,
+            value: value ?? "", // Value can be undefined (if no ":" is present)
+          })
+        )
+      ),
+      RA.toArray
+    )
   } else {
     // FormData has its own format
     return req.body.body.flatMap((entry) => {

+ 40 - 0
packages/hoppscotch-app/helpers/rawKeyValue.ts

@@ -0,0 +1,40 @@
+import * as A from "fp-ts/Array"
+import * as RA from "fp-ts/ReadonlyArray"
+import * as S from "fp-ts/string"
+import { pipe, flow } from "fp-ts/function"
+import { stringArrayJoin } from "./functional/array"
+
+export type RawKeyValueEntry = {
+  key: string
+  value: string
+  active: boolean
+}
+
+const parseRawKeyValueEntry = (str: string): RawKeyValueEntry => {
+  const trimmed = str.trim()
+  const inactive = trimmed.startsWith("#")
+
+  const [key, value] = trimmed.split(":").map(S.trim)
+
+  return {
+    key: inactive ? key.replaceAll(/^#+\s*/g, "") : key, // Remove comment hash and early space
+    value,
+    active: !inactive,
+  }
+}
+
+export const parseRawKeyValueEntries = flow(
+  S.split("\n"),
+  RA.filter((x) => x.trim().length > 0), // Remove lines which are empty
+  RA.map(parseRawKeyValueEntry),
+  RA.toArray
+)
+
+export const rawKeyValueEntriesToString = (entries: RawKeyValueEntry[]) =>
+  pipe(
+    entries,
+    A.map(({ key, value, active }) =>
+      active ? `${key}: ${value}` : `# ${key}: ${value}`
+    ),
+    stringArrayJoin("\n")
+  )

+ 194 - 0
packages/hoppscotch-app/helpers/rules/BodyTransition.ts

@@ -0,0 +1,194 @@
+/**
+ * Defines how body should be updated for movement between different
+ * content-types
+ */
+
+import { pipe } from "fp-ts/function"
+import * as A from "fp-ts/Array"
+import {
+  FormDataKeyValue,
+  HoppRESTReqBody,
+  ValidContentTypes,
+} from "@hoppscotch/data"
+import {
+  parseRawKeyValueEntries,
+  rawKeyValueEntriesToString,
+  RawKeyValueEntry,
+} from "../rawKeyValue"
+
+const ANY_TYPE = Symbol("TRANSITION_RULESET_IGNORE_TYPE")
+// eslint-disable-next-line no-redeclare
+type ANY_TYPE = typeof ANY_TYPE
+
+type BodyType<T extends ValidContentTypes | null | ANY_TYPE> =
+  T extends ValidContentTypes
+    ? HoppRESTReqBody & { contentType: T }
+    : HoppRESTReqBody
+
+type TransitionDefinition<
+  FromType extends ValidContentTypes | null | ANY_TYPE,
+  ToType extends ValidContentTypes | null | ANY_TYPE
+> = {
+  from: FromType
+  to: ToType
+  definition: (
+    currentBody: BodyType<FromType>,
+    targetType: BodyType<ToType>["contentType"]
+  ) => BodyType<ToType>
+}
+
+const rule = <
+  FromType extends ValidContentTypes | null | ANY_TYPE,
+  ToType extends ValidContentTypes | null | ANY_TYPE
+>(
+  input: TransitionDefinition<FromType, ToType>
+) => input
+
+// Use ANY_TYPE to ignore from/dest type
+// Rules apply from top to bottom
+const transitionRuleset = [
+  rule({
+    from: null,
+    to: "multipart/form-data",
+    definition: () => ({
+      contentType: "multipart/form-data",
+      body: [],
+    }),
+  }),
+  rule({
+    from: ANY_TYPE,
+    to: null,
+    definition: () => ({
+      contentType: null,
+      body: null,
+    }),
+  }),
+  rule({
+    from: null,
+    to: ANY_TYPE,
+    definition: (_, targetType) => ({
+      contentType: targetType as unknown as Exclude<
+        // This is guaranteed by the above rules, we just can't tell TS this
+        ValidContentTypes,
+        "multipart/form-data"
+      >,
+      body: "",
+    }),
+  }),
+  rule({
+    from: "multipart/form-data",
+    to: "application/x-www-form-urlencoded",
+    definition: (currentBody, targetType) => ({
+      contentType: targetType,
+      body: pipe(
+        currentBody.body,
+        A.map(
+          ({ key, value, isFile, active }) =>
+            <RawKeyValueEntry>{
+              key,
+              value: isFile ? "" : value,
+              active,
+            }
+        ),
+        rawKeyValueEntriesToString
+      ),
+    }),
+  }),
+  rule({
+    from: "application/x-www-form-urlencoded",
+    to: "multipart/form-data",
+    definition: (currentBody, targetType) => ({
+      contentType: targetType,
+      body: pipe(
+        currentBody.body,
+        parseRawKeyValueEntries,
+        A.map(
+          ({ key, value, active }) =>
+            <FormDataKeyValue>{
+              key,
+              value,
+              active,
+              isFile: false,
+            }
+        )
+      ),
+    }),
+  }),
+  rule({
+    from: ANY_TYPE,
+    to: "multipart/form-data",
+    definition: () => ({
+      contentType: "multipart/form-data",
+      body: [],
+    }),
+  }),
+  rule({
+    from: "multipart/form-data",
+    to: ANY_TYPE,
+    definition: (_, target) => ({
+      contentType: target as Exclude<ValidContentTypes, "multipart/form-data">,
+      body: "",
+    }),
+  }),
+  rule({
+    from: "application/x-www-form-urlencoded",
+    to: ANY_TYPE,
+    definition: (_, target) => ({
+      contentType: target as Exclude<ValidContentTypes, "multipart/form-data">,
+      body: "",
+    }),
+  }),
+  rule({
+    from: ANY_TYPE,
+    to: "application/x-www-form-urlencoded",
+    definition: () => ({
+      contentType: "application/x-www-form-urlencoded",
+      body: "",
+    }),
+  }),
+  rule({
+    from: ANY_TYPE,
+    to: ANY_TYPE,
+    definition: (curr, targetType) => ({
+      contentType: targetType as Exclude<
+        // Above rules ensure this will be the case
+        ValidContentTypes,
+        "multipart/form-data" | "application/x-www-form-urlencoded"
+      >,
+      // Again, above rules ensure this will be the case, can't convince TS tho
+      body: (
+        curr as HoppRESTReqBody & {
+          contentType: Exclude<
+            ValidContentTypes,
+            "multipart/form-data" | "application/x-www-form-urlencoded"
+          >
+        }
+      ).body,
+    }),
+  }),
+] as const
+
+export const applyBodyTransition = <T extends ValidContentTypes | null>(
+  current: HoppRESTReqBody,
+  target: T
+): HoppRESTReqBody & { contentType: T } => {
+  if (current.contentType === target) {
+    console.warn(
+      `Tried to transition body from and to the same content-type '${target}'`
+    )
+    return current as any
+  }
+
+  const transitioner = transitionRuleset.find(
+    (def) =>
+      (def.from === current.contentType || def.from === ANY_TYPE) &&
+      (def.to === target || def.to === ANY_TYPE)
+  )
+
+  if (!transitioner) {
+    throw new Error("Body Type Transition Ruleset is invalid :(")
+  }
+
+  // TypeScript won't be able to figure this out easily :(
+  return (transitioner.definition as any)(current, target)
+}

+ 4 - 3
packages/hoppscotch-app/helpers/strategies/AxiosStrategy.ts

@@ -122,8 +122,9 @@ const axiosWithoutProxy: NetworkStrategy = (req) =>
   )
 
 const axiosStrategy: NetworkStrategy = (req) =>
-  settingsStore.value.PROXY_ENABLED
-    ? axiosWithProxy(req)
-    : axiosWithoutProxy(req)
+  pipe(
+    req,
+    settingsStore.value.PROXY_ENABLED ? axiosWithProxy : axiosWithoutProxy
+  )
 
 export default axiosStrategy

Some files were not shown because too many files changed in this diff