Files
hoppscotch/packages/hoppscotch-common/src/components/http/Request.vue

664 lines
20 KiB
Vue

<template>
<div
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
>
<div
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
>
<div class="relative flex">
<label for="method">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => methodTippyActions.focus()"
>
<span class="select-wrapper">
<input
id="method"
class="flex px-4 py-2 font-semibold transition rounded-l cursor-pointer text-secondaryDark w-26 bg-primaryLight"
:value="tab.document.request.method"
:readonly="!isCustomMethod"
:placeholder="`${t('request.method')}`"
@input="onSelectMethod($event)"
/>
</span>
<template #content="{ hide }">
<div
ref="methodTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-for="(method, index) in methods"
:key="`method-${index}`"
:label="method"
@click="
() => {
updateMethod(method)
hide()
}
"
/>
</div>
</template>
</tippy>
</label>
</div>
<div
class="flex flex-1 transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
>
<SmartEnvInput
v-model="tab.document.request.endpoint"
:placeholder="`${t('request.url')}`"
:auto-complete-source="userHistories"
:inspection-results="tabResults"
@paste="onPasteUrl($event)"
@enter="newSendRequest"
/>
</div>
</div>
<div class="flex mt-2 sm:mt-0">
<HoppButtonPrimary
id="send"
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'action.send'
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
:label="`${!loading ? t('action.send') : t('action.cancel')}`"
class="flex-1 rounded-r-none min-w-20"
@click="!loading ? newSendRequest() : cancelRequest()"
/>
<span class="flex">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => sendTippyActions.focus()"
>
<HoppButtonPrimary
v-tippy="{ theme: 'tooltip' }"
:title="t('app.options')"
:icon="IconChevronDown"
filled
class="rounded-l-none"
/>
<template #content="{ hide }">
<div
ref="sendTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.c="curl.$el.click()"
@keyup.s="show.$el.click()"
@keyup.delete="clearAll.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="curl"
:label="`${t('import.curl')}`"
:icon="IconFileCode"
:shortcut="['C']"
@click="
() => {
showCurlImportModal = !showCurlImportModal
hide()
}
"
/>
<HoppSmartItem
ref="show"
:label="`${t('show.code')}`"
:icon="IconCode2"
:shortcut="['S']"
@click="
() => {
showCodegenModal = !showCodegenModal
hide()
}
"
/>
<HoppSmartItem
ref="clearAll"
:label="`${t('action.clear_all')}`"
:icon="IconRotateCCW"
:shortcut="['⌫']"
@click="
() => {
clearContent()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
<span class="flex ml-2 transition border rounded border-divider">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'request.save'
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
:label="COLUMN_LAYOUT ? `${t('request.save')}` : ''"
filled
:icon="IconSave"
class="flex-1 rounded rounded-r-none"
@click="saveRequest()"
/>
<span class="flex">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => saveTippyActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('app.options')"
:icon="IconChevronDown"
filled
class="rounded rounded-l-none"
/>
<template #content="{ hide }">
<div
ref="saveTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<input
id="request-name"
v-model="tab.document.request.name"
:placeholder="`${t('request.name')}`"
name="request-name"
type="text"
autocomplete="off"
class="mb-2 input !bg-primaryContrast"
@keyup.enter="hide()"
/>
<HoppSmartItem
ref="copyRequestAction"
:label="shareButtonText"
:icon="copyLinkIcon"
:loading="fetchingShareLink"
@click="
() => {
copyRequest()
}
"
/>
<HoppSmartItem
:icon="IconLink2"
:label="`${t('request.view_my_links')}`"
to="/profile"
/>
<hr />
<HoppSmartItem
ref="saveRequestAction"
:label="`${t('request.save_as')}`"
:icon="IconFolderPlus"
@click="
() => {
showSaveRequestModal = true
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</span>
</div>
<HttpImportCurl
:text="curlText"
:show="showCurlImportModal"
@hide-modal="showCurlImportModal = false"
/>
<HttpCodegenModal
v-if="showCodegenModal"
:show="showCodegenModal"
@hide-modal="showCodegenModal = false"
/>
<CollectionsSaveRequest
v-if="showSaveRequestModal"
mode="rest"
:show="showSaveRequestModal"
:request="request"
@hide-modal="showSaveRequestModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings"
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
import { useToast } from "@composables/toast"
import { refAutoReset, useVModel } from "@vueuse/core"
import * as E from "fp-ts/Either"
import { Ref, computed, onBeforeUnmount, ref } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { runMutation } from "~/helpers/backend/GQLClient"
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
import { createShortcode } from "~/helpers/backend/mutations/Shortcode"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { editRESTRequest } from "~/newstore/collections"
import IconCheck from "~icons/lucide/check"
import IconChevronDown from "~icons/lucide/chevron-down"
import IconCode2 from "~icons/lucide/code-2"
import IconCopy from "~icons/lucide/copy"
import IconFileCode from "~icons/lucide/file-code"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconLink2 from "~icons/lucide/link-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconSave from "~icons/lucide/save"
import IconShare2 from "~icons/lucide/share-2"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { RESTTabService } from "~/services/tab/rest"
const t = useI18n()
const interceptorService = useService(InterceptorService)
const methods = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"CONNECT",
"OPTIONS",
"TRACE",
"CUSTOM",
]
const toast = useToast()
const { subscribeToStream } = useStreamSubscriber()
const props = defineProps<{ modelValue: HoppTab<HoppRESTDocument> }>()
const emit = defineEmits(["update:modelValue"])
const tab = useVModel(props, "modelValue", emit)
const newEndpoint = computed(() => {
return tab.value.document.request.endpoint
})
const newMethod = computed(() => {
return tab.value.document.request.method
})
const curlText = ref("")
const loading = ref(false)
const showCurlImportModal = ref(false)
const showCodegenModal = ref(false)
const showSaveRequestModal = ref(false)
const hasNavigatorShare = !!navigator.share
// Template refs
const methodTippyActions = ref<any | null>(null)
const sendTippyActions = ref<any | null>(null)
const saveTippyActions = ref<any | null>(null)
const curl = ref<any | null>(null)
const show = ref<any | null>(null)
const clearAll = ref<any | null>(null)
const copyRequestAction = ref<any | null>(null)
const saveRequestAction = ref<any | null>(null)
const history = useReadonlyStream<RESTHistoryEntry[]>(restHistory$, [])
const requestCancelFunc: Ref<(() => void) | null> = ref(null)
const userHistories = computed(() => {
return history.value.map((history) => history.request.endpoint).slice(0, 10)
})
const newSendRequest = async () => {
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
toast.error(`${t("empty.endpoint")}`)
return
}
ensureMethodInEndpoint()
loading.value = true
// Log the request run into analytics
platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "rest",
strategy: interceptorService.currentInterceptorID.value!,
})
const [cancel, streamPromise] = runRESTRequest$(tab)
const streamResult = await streamPromise
requestCancelFunc.value = cancel
if (E.isRight(streamResult)) {
subscribeToStream(
streamResult.right,
(responseState) => {
if (loading.value) {
// Check exists because, loading can be set to false
// when cancelled
updateRESTResponse(responseState)
}
},
() => {
loading.value = false
},
() => {
// TODO: Change this any to a proper type
const result = (streamResult.right as any).value
if (
result.type === "network_fail" &&
result.error?.error === "NO_PW_EXT_HOOK"
) {
const errorResponse: HoppRESTResponse = {
type: "extension_error",
error: result.error.humanMessage.heading,
component: result.error.component,
req: result.req,
}
updateRESTResponse(errorResponse)
}
loading.value = false
}
)
} else {
loading.value = false
toast.error(`${t("error.script_fail")}`)
let error: Error
if (typeof streamResult.left === "string") {
error = { name: "RequestFailure", message: streamResult.left }
} else {
error = streamResult.left
}
updateRESTResponse({
type: "script_fail",
error,
})
}
}
const ensureMethodInEndpoint = () => {
if (
!/^http[s]?:\/\//.test(newEndpoint.value) &&
!newEndpoint.value.startsWith("<<")
) {
const domain = newEndpoint.value.split(/[/:#?]+/)[0]
if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) {
tab.value.document.request.endpoint =
"http://" + tab.value.document.request.endpoint
} else {
tab.value.document.request.endpoint =
"https://" + tab.value.document.request.endpoint
}
}
}
const onPasteUrl = (e: { pastedValue: string; prevValue: string }) => {
if (!e) return
const pastedData = e.pastedValue
if (isCURL(pastedData)) {
showCurlImportModal.value = true
curlText.value = pastedData
tab.value.document.request.endpoint = e.prevValue
}
}
function isCURL(curl: string) {
return curl.includes("curl ")
}
const cancelRequest = () => {
loading.value = false
requestCancelFunc.value?.()
updateRESTResponse(null)
}
const updateMethod = (method: string) => {
tab.value.document.request.method = method
}
const onSelectMethod = (e: Event | any) => {
// type any because of value property not being recognized by TS in the event.target object. It is a valid property though.
updateMethod(e.target.value)
}
const clearContent = () => {
tab.value.document.request = getDefaultRESTRequest()
}
const updateRESTResponse = (response: HoppRESTResponse | null) => {
tab.value.document.response = response
}
const copyLinkIcon = refAutoReset<
typeof IconShare2 | typeof IconCopy | typeof IconCheck
>(hasNavigatorShare ? IconShare2 : IconCopy, 1000)
const shareLink = ref<string | null>("")
const fetchingShareLink = ref(false)
const shareButtonText = computed(() => {
if (shareLink.value) {
return shareLink.value
} else if (fetchingShareLink.value) {
return t("state.loading")
} else {
return t("request.copy_link")
}
})
const copyRequest = async () => {
if (shareLink.value) {
copyShareLink(shareLink.value)
} else {
shareLink.value = ""
fetchingShareLink.value = true
const shortcodeResult = await createShortcode(tab.value.document.request)()
platform.analytics?.logEvent({
type: "HOPP_SHORTCODE_CREATED",
})
if (E.isLeft(shortcodeResult)) {
toast.error(`${shortcodeResult.left.error}`)
shareLink.value = `${t("error.something_went_wrong")}`
} else if (E.isRight(shortcodeResult)) {
shareLink.value = `/${shortcodeResult.right.createShortcode.id}`
copyShareLink(shareLink.value)
}
fetchingShareLink.value = false
}
}
const copyShareLink = (shareLink: string) => {
const link = `${
import.meta.env.VITE_SHORTCODE_BASE_URL ?? "https://hopp.sh"
}/r${shareLink}`
if (navigator.share) {
const time = new Date().toLocaleTimeString()
const date = new Date().toLocaleDateString()
navigator.share({
title: "Hoppscotch",
text: `Hoppscotch • Open source API development ecosystem at ${time} on ${date}`,
url: link,
})
} else {
copyLinkIcon.value = IconCheck
copyToClipboard(link)
toast.success(`${t("state.copied_to_clipboard")}`)
}
}
const cycleUpMethod = () => {
const currentIndex = methods.indexOf(newMethod.value)
if (currentIndex === -1) {
// Most probs we are in CUSTOM mode
// Cycle up from CUSTOM is PATCH
updateMethod("PATCH")
} else if (currentIndex === 0) {
updateMethod("CUSTOM")
} else {
updateMethod(methods[currentIndex - 1])
}
}
const cycleDownMethod = () => {
const currentIndex = methods.indexOf(newMethod.value)
if (currentIndex === -1) {
// Most probs we are in CUSTOM mode
// Cycle down from CUSTOM is GET
updateMethod("GET")
} else if (currentIndex === methods.length - 1) {
updateMethod("GET")
} else {
updateMethod(methods[currentIndex + 1])
}
}
const saveRequest = () => {
const saveCtx = tab.value.document.saveContext
if (!saveCtx) {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "user-collection") {
const req = tab.value.document.request
try {
editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, req)
tab.value.document.isDirty = false
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "rest",
createdNow: false,
workspaceType: "personal",
})
toast.success(`${t("request.saved")}`)
} catch (e) {
tab.value.document.saveContext = undefined
saveRequest()
}
} else if (saveCtx.originLocation === "team-collection") {
const req = tab.value.document.request
// TODO: handle error case (NOTE: overwriteRequestTeams is async)
try {
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "rest",
createdNow: false,
workspaceType: "team",
})
runMutation(UpdateRequestDocument, {
requestID: saveCtx.requestID,
data: {
title: req.name,
request: JSON.stringify(req),
},
})().then((result) => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
} else {
tab.value.document.isDirty = false
toast.success(`${t("request.saved")}`)
}
})
} catch (error) {
showSaveRequestModal.value = true
toast.error(`${t("error.something_went_wrong")}`)
console.error(error)
}
}
}
const request = ref<HoppRESTRequest | null>(null)
onBeforeUnmount(() => {
if (loading.value) cancelRequest()
})
defineActionHandler("request.send-cancel", () => {
if (!loading.value) newSendRequest()
else cancelRequest()
})
defineActionHandler("request.reset", clearContent)
defineActionHandler("request.copy-link", copyRequest)
defineActionHandler("request.method.next", cycleDownMethod)
defineActionHandler("request.method.prev", cycleUpMethod)
defineActionHandler("request.save", saveRequest)
defineActionHandler(
"request.save-as",
(
req:
| {
requestType: "rest"
request: HoppRESTRequest
}
| {
requestType: "gql"
request: HoppGQLRequest
}
) => {
showSaveRequestModal.value = true
if (req && req.requestType === "rest") {
request.value = req.request
}
}
)
defineActionHandler("request.method.get", () => updateMethod("GET"))
defineActionHandler("request.method.post", () => updateMethod("POST"))
defineActionHandler("request.method.put", () => updateMethod("PUT"))
defineActionHandler("request.method.delete", () => updateMethod("DELETE"))
defineActionHandler("request.method.head", () => updateMethod("HEAD"))
defineActionHandler("request.import-curl", () => {
showCurlImportModal.value = true
})
defineActionHandler("request.show-code", () => {
showCodegenModal.value = true
})
const isCustomMethod = computed(() => {
return (
tab.value.document.request.method === "CUSTOM" ||
!methods.includes(newMethod.value)
)
})
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
const inspectionService = useService(InspectionService)
const tabs = useService(RESTTabService)
const tabResults = inspectionService.getResultViewFor(tabs.currentTabID.value)
</script>