From 5d1d24870a43c2ff313f7257e9beb32007f3de0b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 15 Aug 2026 10:03:43 -0700 Subject: [PATCH] Import from a URL in the Import Data dialog (#551) --- .../components/ImportDataDialog.tsx | 162 +++++++++++++----- apps/yaak-client/lib/importData.tsx | 30 ++-- .../yaak-app-client/bindings/gen_rpc.ts | 4 +- crates-tauri/yaak-app-client/src/import.rs | 84 ++++++++- crates-tauri/yaak-app-client/src/lib.rs | 9 +- crates-tauri/yaak-app-client/src/rpc_ext.rs | 12 ++ 6 files changed, 240 insertions(+), 61 deletions(-) diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 087721c7..01e098e6 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -1,56 +1,138 @@ -import { VStack } from "@yaakapp-internal/ui"; -import { useState } from "react"; +import { platform } from "@yaakapp-internal/platform"; +import { Icon, VStack } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useEffect, useRef, useState } from "react"; import { useLocalStorage } from "react-use"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { Button } from "./core/Button"; -import { SelectFile } from "./SelectFile"; +import { PlainInput } from "./core/PlainInput"; interface Props { - importData: (filePath: string) => Promise; + importFile: (filePath: string) => Promise; + importUrl: (url: string) => Promise; } -export function ImportDataDialog({ importData }: Props) { +/** + * An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a + * bare host like `example.com/openapi.json` still works (the backend defaults it to https). + */ +function isFilePath(value: string): boolean { + return ( + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith("~/") || + value.startsWith("\\\\") || + /^[a-zA-Z]:[\\/]/.test(value) + ); +} + +function fileName(path: string): string { + return path.split(/[/\\]/).at(-1) || path; +} + +export function ImportDataDialog({ importFile, importUrl }: Props) { const [isLoading, setIsLoading] = useState(false); - const [filePath, setFilePath] = useLocalStorage("importFilePath", null); + // A file path or a URL. Both inputs write here, so there is only ever one thing to import + const [source, setSource] = useLocalStorage("importPathOrUrl", null); + const [forceUpdateKey, setForceUpdateKey] = useState(0); + const [isHovering, setIsHovering] = useState(false); + const ref = useRef(null); + const trimmedSource = source?.trim() ?? ""; + const filePath = isFilePath(trimmedSource) ? trimmedSource : null; + + const selectSource = (value: string) => { + setSource(value); + // Remount the input so it shows the path of the newly-picked file + setForceUpdateKey((k) => k + 1); + }; + + // Accept a file dropped anywhere on the dialog, the way SelectFile does for its button + useEffect(() => { + return platform.window.onDragDrop((event) => { + if (event.type === "over") { + const p = event.position; + const r = ref.current?.getBoundingClientRect(); + if (r == null) return; + setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom); + } else if (event.type === "drop" && isHovering) { + const p = event.paths[0]; + if (p) selectSource(p); + setIsHovering(false); + } else { + setIsHovering(false); + } + }); + }, [isHovering, setSource]); + + const handleSelectFile = async () => { + const selected = await platform.dialog.open({ title: "Select File", multiple: false }); + if (selected == null) return; + selectSource(selected); + }; + + const handleImport = async () => { + setIsLoading(true); + try { + if (filePath != null) { + await importFile(filePath); + } else { + await importUrl(trimmedSource); + } + } finally { + setIsLoading(false); + } + }; return ( - + - -
    -
  • OpenAPI 3.0, 3.1
  • -
  • Postman Collection v2, v2.1
  • -
  • Insomnia v4+
  • -
  • Swagger 2.0
  • -
  • - Curl commands (or paste into URL) -
  • -
-
- - setFilePath(filePath)} - /> - {filePath && ( - + + + + + ); diff --git a/apps/yaak-client/lib/importData.tsx b/apps/yaak-client/lib/importData.tsx index a1b7e605..4f203ea9 100644 --- a/apps/yaak-client/lib/importData.tsx +++ b/apps/yaak-client/lib/importData.tsx @@ -2,11 +2,9 @@ import type { BatchUpsertResult } from "@yaakapp-internal/models"; import { FormattedError, VStack } from "@yaakapp-internal/ui"; import { Button } from "../components/core/Button"; import { ImportDataDialog } from "../components/ImportDataDialog"; -import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; import { showAlert } from "./alert"; import { showDialog } from "./dialog"; -import { jotaiStore } from "./jotai"; import { pluralizeCount } from "./pluralize"; import { router } from "./router"; import { rpc } from "./rpc"; @@ -28,12 +26,9 @@ export const importData = createFastMutation({ title: "Import Data", size: "sm", render: ({ hide }) => { - const importAndHide = async (filePath: string) => { + const importAndHide = async (runImport: () => Promise) => { try { - const didImport = await performImport(filePath); - if (!didImport) { - return; - } + await finishImport(await runImport()); resolve(); } catch (err) { reject(err); @@ -41,20 +36,23 @@ export const importData = createFastMutation({ hide(); } }; - return ; + return ( + + importAndHide(() => rpc("cmd_import_data", { filePath })) + } + importUrl={(url) => + importAndHide(() => rpc("cmd_import_url", { url })) + } + /> + ); }, }); }); }, }); -async function performImport(filePath: string): Promise { - const activeWorkspace = jotaiStore.get(activeWorkspaceAtom); - const imported = await rpc("cmd_import_data", { - filePath, - workspaceId: activeWorkspace?.id, - }); - +async function finishImport(imported: BatchUpsertResult): Promise { const importedWorkspace = imported.workspaces[0]; showDialog({ @@ -103,6 +101,4 @@ async function performImport(filePath: string): Promise { search: { environment_id: environmentId }, }); } - - return true; } diff --git a/crates-tauri/yaak-app-client/bindings/gen_rpc.ts b/crates-tauri/yaak-app-client/bindings/gen_rpc.ts index 299dce27..721370cf 100644 --- a/crates-tauri/yaak-app-client/bindings/gen_rpc.ts +++ b/crates-tauri/yaak-app-client/bindings/gen_rpc.ts @@ -141,6 +141,8 @@ export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null export type CmdImportDataReq = { filePath: string, }; +export type CmdImportUrlReq = { url: string, }; + export type CmdMetadataReq = Record; export type CmdNewChildWindowReq = { url: string, label: string, title: string, innerSize: [number, number], }; @@ -242,4 +244,4 @@ export type ModelsWorkspaceModelsReq = { workspaceId: string | null, }; * The wire schema, exported to TypeScript. Field name = command name, * tuple = (request payload, response payload). */ -export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; +export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, BatchUpsertResult], cmd_import_url: [CmdImportUrlReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; diff --git a/crates-tauri/yaak-app-client/src/import.rs b/crates-tauri/yaak-app-client/src/import.rs index ddb2932e..3ed04903 100644 --- a/crates-tauri/yaak-app-client/src/import.rs +++ b/crates-tauri/yaak-app-client/src/import.rs @@ -5,6 +5,7 @@ use std::fs::read_to_string; use std::io::ErrorKind; use tauri::{Manager, Runtime, WebviewWindow}; use yaak::import::{self, ImportDataParams}; +use yaak_api::{ApiClientKind, yaak_api_client}; use yaak_core::WorkspaceContext; use yaak_models::util::BatchUpsertResult; use yaak_plugins::manager::PluginManager; @@ -13,10 +14,25 @@ use yaak_tauri_utils::window::WorkspaceWindowTrait; pub(crate) async fn import_data( window: &WebviewWindow, file_path: &str, +) -> Result { + let contents = read_import_file(file_path)?; + import_contents(window, &contents).await +} + +pub(crate) async fn import_url( + window: &WebviewWindow, + url: &str, +) -> Result { + let contents = fetch_import_url(window, url).await?; + import_contents(window, &contents).await +} + +async fn import_contents( + window: &WebviewWindow, + contents: &str, ) -> Result { let plugin_manager = window.state::(); let query_manager = window.db_manager(); - let file = read_import_file(file_path)?; let plugin_context = window.plugin_context(); let workspace_context = WorkspaceContext { workspace_id: window.workspace_id(), @@ -30,11 +46,57 @@ pub(crate) async fn import_data( plugin_manager: &plugin_manager, plugin_context: &plugin_context, workspace_context, - contents: &file, + contents, }) .await?) } +/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same +/// pipeline as a file on disk. +/// +/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy, +/// client certificate, or certificate-validation settings. Requests are unauthenticated, so +/// specs behind auth must still be downloaded manually and imported as a file. +async fn fetch_import_url(window: &WebviewWindow, url: &str) -> Result { + let url = normalize_import_url(url)?; + let app_version = window.app_handle().package_info().version.to_string(); + let response = yaak_api_client(ApiClientKind::App, &app_version)? + .get(&url) + // The API client defaults to JSON, but specs are just as often YAML + .header("Accept", "*/*") + .send() + .await + .map_err(|err| Error::GenericError(format!("Failed to fetch {url}: {err}")))?; + + let status = response.status(); + if !status.is_success() { + return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}"))); + } + + response + .text() + .await + .map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}"))) +} + +fn normalize_import_url(url: &str) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err(Error::GenericError("Import URL must not be empty".to_string())); + } + + if url.starts_with("http://") || url.starts_with("https://") { + return Ok(url.to_string()); + } + + match url.split_once("://") { + Some((scheme, _)) => { + Err(Error::GenericError(format!("Import URL must be http or https, but got {scheme}"))) + } + None => Ok(format!("https://{url}")), + } +} + fn read_import_file(file_path: &str) -> Result { read_to_string(file_path).map_err(|err| { if err.kind() == ErrorKind::InvalidData { @@ -71,4 +133,22 @@ mod tests { remove_file(path).expect("remove binary fixture"); } + + #[test] + fn normalize_import_url_defaults_to_https() { + assert_eq!( + normalize_import_url(" example.com/openapi.yaml ").unwrap(), + "https://example.com/openapi.yaml" + ); + assert_eq!( + normalize_import_url("http://example.com/openapi.yaml").unwrap(), + "http://example.com/openapi.yaml" + ); + } + + #[test] + fn normalize_import_url_rejects_other_schemes() { + assert!(normalize_import_url("file:///tmp/openapi.yaml").is_err()); + assert!(normalize_import_url(" ").is_err()); + } } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 4d14de63..8aa3a6f7 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -4,7 +4,7 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request}; use crate::http_request::{resolve_http_request, send_http_request}; -use crate::import::import_data; +use crate::import::{import_data, import_url}; use crate::models_ext::{BlobManagerExt, QueryManagerExt}; use crate::notifications::YaakNotifier; use crate::render::{render_grpc_request, render_json_value, render_template}; @@ -1165,6 +1165,13 @@ async fn cmd_import_data( import_data(&window, file_path).await } +async fn cmd_import_url( + window: WebviewWindow, + url: &str, +) -> YaakResult { + import_url(&window, url).await +} + async fn cmd_http_request_actions( window: WebviewWindow, plugin_manager: State<'_, PluginManager>, diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index bac4390b..2a8361a9 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -320,6 +320,13 @@ pub(crate) struct CmdImportDataReq { pub file_path: String, } +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub(crate) struct CmdImportUrlReq { + pub url: String, +} + #[derive(Debug, Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub(crate) struct CmdHttpRequestActionsReq {} @@ -1026,6 +1033,10 @@ async fn cmd_import_data(ctx: ClientCtx, req: CmdImportDataReq) - Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?) } +async fn cmd_import_url(ctx: ClientCtx, req: CmdImportUrlReq) -> Result { + Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?) +} + async fn cmd_http_request_actions(ctx: ClientCtx, _req: CmdHttpRequestActionsReq) -> Result> { Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::()).await?) } @@ -1400,6 +1411,7 @@ rpc_commands! { cmd_get_sse_events(CmdGetSseEventsReq) -> Vec, cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec, cmd_import_data(CmdImportDataReq) -> BatchUpsertResult, + cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult, cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec, cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec, cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),