Import from a URL in the Import Data dialog (#551)

This commit is contained in:
Gregory Schier
2026-08-15 10:03:43 -07:00
committed by GitHub
parent 6f0d0ef275
commit 5d1d24870a
6 changed files with 240 additions and 61 deletions
+122 -40
View File
@@ -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<void>;
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
}
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<boolean>(false);
const [filePath, setFilePath] = useLocalStorage<string | null>("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<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(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 (
<VStack space={5} className="pb-4">
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}>
<ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li>
<li>Postman Collection v2, v2.1</li>
<li>Insomnia v4+</li>
<li>Swagger 2.0</li>
<li>
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
</li>
</ul>
</VStack>
<VStack space={2}>
<SelectFile
filePath={filePath ?? null}
onChange={({ filePath }) => setFilePath(filePath)}
/>
{filePath && (
<Button
color="primary"
disabled={!filePath || isLoading}
isLoading={isLoading}
size="sm"
onClick={async () => {
setIsLoading(true);
try {
await importData(filePath);
} finally {
setIsLoading(false);
}
}}
>
{isLoading ? "Importing" : "Import"}
</Button>
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
</div>
</button>
<VStack space={2}>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
size="sm"
onClick={handleImport}
>
{isLoading ? "Importing" : "Import"}
</Button>
</VStack>
</VStack>
);
+13 -17
View File
@@ -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<BatchUpsertResult>) => {
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 <ImportDataDialog importData={importAndHide} />;
return (
<ImportDataDialog
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
}
/>
);
},
});
});
},
});
async function performImport(filePath: string): Promise<boolean> {
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
const imported = await rpc<BatchUpsertResult>("cmd_import_data", {
filePath,
workspaceId: activeWorkspace?.id,
});
async function finishImport(imported: BatchUpsertResult): Promise<void> {
const importedWorkspace = imported.workspaces[0];
showDialog({
@@ -103,6 +101,4 @@ async function performImport(filePath: string): Promise<boolean> {
search: { environment_id: environmentId },
});
}
return true;
}
File diff suppressed because one or more lines are too long
+82 -2
View File
@@ -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<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
import_contents(window, &contents).await
}
pub(crate) async fn import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<BatchUpsertResult> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
}
async fn import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>();
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<R: Runtime>(
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<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> Result<String> {
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<String> {
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<String> {
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());
}
}
+8 -1
View File
@@ -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<R: Runtime>(
import_data(&window, file_path).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await
}
async fn cmd_http_request_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
@@ -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<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
}
@@ -1400,6 +1411,7 @@ rpc_commands! {
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),