feat(common): retry import openapi from url with proxy interceptor on network error (#5225)

Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
Anwarul Islam 2025-07-10 16:43:31 +06:00 committed by GitHub
parent 3452e721fa
commit 0b605fe9cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 153 additions and 35 deletions

View file

@ -669,7 +669,14 @@
"import_summary_responses_title": "Responses",
"import_summary_pre_request_scripts_title": "Pre-request scripts",
"import_summary_post_request_scripts_title": "Post request scripts",
"import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now."
"import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now.",
"cors_error_modal": {
"title": "CORS Error Detected",
"description": "The import failed due to CORS (Cross-Origin Resource Sharing) restrictions imposed by the server.",
"explanation": "This is a security feature that prevents web pages from making requests to different domains. You can retry using our proxy service to bypass this restriction.",
"url_label": "Attempted URL",
"retry_with_proxy": "Retry with Proxy"
}
},
"instances": {
"switch": "Switch Hoppscotch Instance",

View file

@ -1,11 +1,11 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AccessTokens: typeof import('./components/accessTokens/index.vue')['default']
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
@ -124,6 +124,31 @@ declare module '@vue/runtime-core' {
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
HistoryPersonal: typeof import('./components/history/Personal.vue')['default']
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartTree: typeof import('@hoppscotch/ui')['HoppSmartTree']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
HttpAuthorizationAkamaiEG: typeof import('./components/http/authorization/AkamaiEG.vue')['default']
HttpAuthorizationApiKey: typeof import('./components/http/authorization/ApiKey.vue')['default']
@ -179,7 +204,24 @@ declare module '@vue/runtime-core' {
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideActivity: typeof import('~icons/lucide/activity')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportCorsErrorModal: typeof import('./components/importExport/CorsErrorModal.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default']
ImportExportImportExportStepsAllCollectionImport: typeof import('./components/importExport/ImportExportSteps/AllCollectionImport.vue')['default']
@ -248,5 +290,4 @@ declare module '@vue/runtime-core' {
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
}
}

View file

@ -1,6 +1,21 @@
<template>
<div class="space-y-4">
<div v-if="showCorsError">
<div class="flex items-start space-x-4">
<icon-lucide-alert-triangle
class="text-yellow-500 flex-shrink-0 mt-1"
/>
<div>
<p class="text-secondaryDark">
{{ t("import.cors_error_modal.description") }}
</p>
<p class="text-secondaryLight mt-2">
{{ t("import.cors_error_modal.explanation") }}
</p>
</div>
</div>
</div>
<div v-else>
<p class="flex items-center">
<span
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark"
@ -33,29 +48,34 @@
<div>
<HoppButtonPrimary
class="w-full"
:label="t('import.title')"
:label="
showCorsError
? t('import.cors_error_modal.retry_with_proxy')
: t('import.title')
"
:disabled="disableImportCTA"
:loading="isFetchingUrl || loading"
@click="fetchUrlData"
@click="showCorsError ? retryWithProxy() : fetchUrlData()"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "~/composables/toast"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { useService } from "dioc/vue"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { computed, ref, watch } from "vue"
import { useToast } from "~/composables/toast"
import { parseBodyAsJSONOrYAML } from "~/helpers/functional/json"
import { ProxyKernelInterceptorService } from "~/platform/std/kernel-interceptors/proxy"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
const interceptorService = useService(KernelInterceptorService)
const proxyInterceptorService = useService(ProxyKernelInterceptorService)
const t = useI18n()
const toast = useToast()
const props = withDefaults(
@ -74,8 +94,8 @@ const emit = defineEmits<{
const inputChooseGistToImportFrom = ref<string>("")
const hasURL = ref(false)
const isFetchingUrl = ref(false)
const showCorsError = ref(false)
watch(inputChooseGistToImportFrom, (url) => {
hasURL.value = !!url
@ -83,9 +103,22 @@ watch(inputChooseGistToImportFrom, (url) => {
const disableImportCTA = computed(() => !hasURL.value || props.loading)
const isCorsError = (error: any): boolean => {
// Check for common CORS error patterns
return (
error?.kind === "network" ||
error?.message?.includes("CORS") ||
error?.message?.includes("Access to fetch") ||
error?.message?.includes("Cross-Origin") ||
error?.code === "ERR_NETWORK" ||
error?.name === "TypeError"
)
}
const urlFetchLogic =
props.fetchLogic ??
async function (url: string) {
try {
const { response } = interceptorService.execute({
id: Date.now(),
url: url,
@ -95,32 +128,69 @@ const urlFetchLogic =
const res = await response
if (E.isLeft(res)) {
return E.left("REQUEST_FAILED")
}
if (E.isRight(res)) {
const responsePayload = parseBodyAsJSONOrYAML<unknown>(res.right.body)
if (O.isSome(responsePayload)) {
// stringify the response payload
return E.right(JSON.stringify(responsePayload.value))
}
return E.left("REQUEST_FAILED")
}
// Return the actual error from the failed request
return E.left(E.isLeft(res) ? res.left : "REQUEST_FAILED")
} catch (error) {
// Return the caught error for proper CORS detection
return E.left(error)
}
}
const retryWithProxy = async () => {
isFetchingUrl.value = true
try {
// Store the current interceptor to restore later
const previousInterceptorId = interceptorService.getCurrentId()
// Switch to proxy interceptor
interceptorService.setActive(proxyInterceptorService.id)
// Retry the request with proxy
const res = await urlFetchLogic(inputChooseGistToImportFrom.value)
// Restore previous interceptor
if (previousInterceptorId) {
interceptorService.setActive(previousInterceptorId)
}
if (E.isRight(res)) {
showCorsError.value = false
emit("importFromURL", res.right)
} else {
toast.error(t("import.failed"))
}
} catch (error) {
toast.error(t("import.failed"))
} finally {
isFetchingUrl.value = false
}
}
async function fetchUrlData() {
isFetchingUrl.value = true
const res = await urlFetchLogic(inputChooseGistToImportFrom.value)
if (E.isLeft(res)) {
// @ts-ignore
if (isCorsError(res.left?.error)) {
showCorsError.value = true
} else {
toast.error(t("import.failed"))
}
isFetchingUrl.value = false
return
}
emit("importFromURL", res.right)
isFetchingUrl.value = false
}
</script>