Add Bruno imports and a file-tree importer API (#706)

This commit is contained in:
Gregory Schier authored and GitHub committed 2026-09-24 14:50:44 -07:00
1 parent 46464cfacf
commit aa10c44a45
109 files changed
+4570 -765

No files matched your search

+2 -1
View File
@@ -3,6 +3,7 @@
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
"apps/yaak-client/routeTree.gen.ts",
"plugins/importer-bruno/tests/fixtures/upstream/**"
]
}
@@ -1,12 +1,12 @@
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useId, useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const formId = useId();
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? "Cloning..." : "Clone Repository"}
</Button>
<DialogFooter
actions={[
{
label: isCloning ? "Cloning..." : "Clone Repository",
color: "primary",
form: formId,
disabled: !url || !directory || isCloning,
isLoading: isCloning,
},
]}
/>
</VStack>
);
}
@@ -1,10 +1,10 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useState } from "react";
import { useId, useState } from "react";
import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
const formId = useId();
return (
<form
className="pb-3 flex flex-col gap-3"
id={formId}
className="flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
<DialogFooter
inline
actions={[
{
label: "Create Environment",
color: "secondary",
form: formId,
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
},
]}
/>
</form>
);
}
@@ -2,13 +2,13 @@ import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useId, useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
@@ -26,12 +26,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
const formId = useId();
return (
<VStack
as="form"
id={formId}
space={3}
alignItems="start"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name });
@@ -89,9 +90,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
title="Enable Encryption"
/>
</div>
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
</VStack>
);
}
@@ -1,6 +1,6 @@
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui";
import { VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
@@ -8,9 +8,9 @@ import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -87,8 +87,8 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6">
<>
<VStack space={3} className="pb-4">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
@@ -141,29 +141,22 @@ function ExportDataDialogContent({
/>
</DetailsBanner>
</VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div>
<DialogFooter
leftSlot={
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button
</Link>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
}
actions={[
{ label: "Cancel", onClick: onHide },
{
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
color: "primary",
disabled: noneSelected,
onClick: () => handleExport(),
},
]}
/>
</>
);
}
@@ -239,6 +239,7 @@ export function GrpcEditor({
showDialog({
title: "Configure Schema",
size: "md",
className: "h-[30rem]",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
@@ -1,11 +1,11 @@
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { DialogFooter } from "./core/Dialog";
import { PathList } from "./core/PathList";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
}
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
function GrpcProtoSelectionDialogWithRequest({
request,
onDone,
}: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles);
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
}
return (
<VStack className="flex-col-reverse mb-3" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
<>
<VStack space={5} className="pb-4">
{reflectError && (
<Banner color="warning">
<h1 className="font-bold">
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
{protoFiles.length > 0 && (
<table className="w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="text-text-subtlest" colSpan={3}>
Added File Paths
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => {
const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return (
<tr key={f + i} className="group">
<td>
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td>
<td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && ".../"}
{parts.slice(-3).join("/")}
</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
variant="border"
size="xs"
icon="trash"
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
<PathList
items={protoFiles.map((path) => ({
path,
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
}))}
onRemove={async (path) => {
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
await grpc.reflect.refetch();
}}
addLabel="Add proto files or an import folder"
addItems={[
{
label: "Proto files",
icon: "file_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
},
},
{
label: "Import folder",
icon: "folder_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
},
},
]}
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{reflectionUnimplemented && protoFiles.length === 0 && (
<Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
</VStack>
</VStack>
<DialogFooter
actions={[
{
label: "Refresh Schema",
isLoading: grpc.reflect.isFetching,
disabled: grpc.reflect.isFetching,
onClick: () => grpc.reflect.refetch(),
},
{ label: "Done", color: "primary", onClick: onDone },
]}
/>
</>
);
}
+247 -279
View File
@@ -1,33 +1,36 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import {
ImportSourceList,
type ImportSourceDetection,
type ImportSourcePath,
InlineButton,
toSourcePath,
} from "./ImportSourceList";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
detectSource: (source: ImportSourcePath) => Promise<string>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
@@ -35,25 +38,6 @@ interface Props {
onError: (err: unknown) => void;
}
/**
* 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;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
@@ -82,30 +66,19 @@ export function ImportDataDialog(props: Props) {
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
planSources,
detectSource,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [sourceError, setSourceError] = useState<unknown>(null);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
@@ -113,65 +86,82 @@ function LoadedImportDataDialog({
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
const initialPaths = useMemo(
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
[initialSources],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
const detect = useCallback(
(added: ImportSourcePath[]) => {
setDetections((prev) => ({
...prev,
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
}));
for (const source of added) {
const settle = (detection: ImportSourceDetection) =>
setDetections((prev) =>
source.path in prev ? { ...prev, [source.path]: detection } : prev,
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useState<string | null>(prefill?.origin ?? 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;
detectSource(source).then(
(importer) => settle({ status: "ok", importer }),
(err) => settle({ status: "error", message: errorMessage(err) }),
);
}
},
[detectSource],
);
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
useEffect(() => {
detect(initialPaths);
}, [detect, initialPaths]);
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
const addSources = useCallback(
(added: ImportSourcePath[]) => {
setSourceError(null);
const fresh = added.filter(({ path }) => {
if (knownPaths.current.has(path)) return false;
knownPaths.current.add(path);
return true;
});
if (fresh.length === 0) return;
setSources((existing) => [...existing, ...fresh]);
detect(fresh);
},
[detect],
);
const removeSource = (path: string) => {
setSourceError(null);
knownPaths.current.delete(path);
setSources((current) => current.filter((source) => source.path !== path));
setDetections(({ [path]: _removed, ...rest }) => rest);
};
// 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]);
useEffect(() => {
if (trimmedSource === "") {
let cancelled = false;
if (sources.length === 0) {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
Promise.all(
sources.map(({ path, kind }) =>
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
),
)
.then((found) => {
if (!cancelled) setOriginSources(found.flat());
})
.catch(() => setOriginSources([]));
}, 300);
.catch(() => {
if (!cancelled) setOriginSources([]);
});
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
}, [sources, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
// The one workspace these sources are linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
@@ -193,32 +183,6 @@ function LoadedImportDataDialog({
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
@@ -226,21 +190,18 @@ function LoadedImportDataDialog({
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
setSourceError(null);
try {
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
const nextPlan = await planSources(sources, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
setSourceError(err);
} finally {
setIsLoading(false);
}
@@ -258,8 +219,6 @@ function LoadedImportDataDialog({
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
@@ -313,48 +272,82 @@ function LoadedImportDataDialog({
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [pluralize("New workspace", names.length), names.join(", ")];
}
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return [
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
names.join(", "),
];
}
const { workspaceId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return ["Destination", name];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
const planned = plan.resources.workspaces[0];
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
return {
key: existing?.id ?? planned?.id ?? "workspace",
const destinations = existing ? [existing] : plan.resources.workspaces;
if (destinations.length === 1) {
const workspace = destinations[0]!;
return [
{
key: workspace.id,
data: {
kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: itemTree,
};
children: buildItemTree(items),
},
];
}
const workspaceById = new Map(
[
...plan.resources.environments,
...plan.resources.folders,
...plan.resources.httpRequests,
...plan.resources.grpcRequests,
...plan.resources.websocketRequests,
].map((resource) => [resource.id, resource.workspaceId]),
);
return destinations.map((workspace) => ({
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
),
}));
})();
return (
<>
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings.map((warning) => (
{plan.warnings
.filter(
(warning, index, warnings) =>
warnings.findIndex(
(other) =>
other.title === warning.title &&
other.detail === warning.detail &&
other.level === warning.level,
) === index,
)
.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
key={`${warning.level}:${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
@@ -370,100 +363,69 @@ function LoadedImportDataDialog({
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
{workspaceRoots.map((root) => (
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
<CheckboxTree
node={workspaceRoot}
node={root}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
isCollapsedByDefault={(n) =>
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
(n.data.kind === "item" && n.data.item.action === "ignored")
}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
renderRow={(n) => (
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
)}
/>
</div>
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
))}
</VStack>
<DialogFooter
leftSlot={
footerNote !== "" ? (
<div className="text-xs text-text-subtle">{footerNote}</div>
) : undefined
}
actions={[
{
label: "Back",
disabled: isLoading,
onClick: () => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
},
},
{
label: isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
: "Done",
color: "primary",
isLoading,
onClick: handleCommit,
},
]}
/>
</>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<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-subtle",
)}
const destinationPicker = (
<HStack
space={2}
alignItems="center"
className={classNames(workspaces.length === 0 && "hidden")}
>
<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, curl, and Yaak exports
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
<Select
name="import-destination-kind"
label="Import location"
label="Import into"
labelPosition="left"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
@@ -492,17 +454,33 @@ function LoadedImportDataDialog({
]}
/>
)}
{lastImported != null && destinationWorkspaceId != null ? (
</HStack>
);
return (
<>
<VStack space={4} className="h-full pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<ImportSourceList
sources={sources}
detections={detections}
disabled={isLoading}
onAdd={addSources}
onRemove={removeSource}
onError={setSourceError}
/>
{sourceError != null && (
<Banner color="warning">
<FormattedError>{errorMessage(sourceError)}</FormattedError>
</Banner>
)}
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
<InlineButton
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
@@ -513,36 +491,28 @@ function LoadedImportDataDialog({
}}
>
{linkedWorkspace.name}
</button>
</InlineButton>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}”`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={
trimmedSource === "" ||
<DialogFooter
leftSlot={destinationPicker}
actions={[
{ label: "Cancel", disabled: isLoading, onClick: cancel },
{
label: isLoading ? "Analyzing" : "Preview Import",
color: "primary",
disabled:
sources.length === 0 ||
!allDetected ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading}
onClick={handlePreview}
>
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</HStack>
</VStack>
(destinationKind === "other" && otherWorkspaceId == null),
isLoading,
onClick: handlePreview,
},
]}
/>
</>
);
}
@@ -556,10 +526,12 @@ function ImportTreeRow({
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
{row.kind === "destination" && (
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
)}
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
<ActionChip label="new" help="Created by this import" color="success" />
)}
</>
);
@@ -569,11 +541,6 @@ function ImportTreeRow({
const label = actionLabel(item);
return (
<>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
@@ -593,17 +560,7 @@ function ImportTreeRow({
</div>
) : (
label != null && (
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
/>
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
)
)}
</>
@@ -613,26 +570,37 @@ function ImportTreeRow({
function ActionChip({
label,
help,
className,
color,
}: {
label: string;
help: string | null;
className?: string;
color: ComponentProps<typeof Chip>["color"];
}) {
return (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
<Chip color={color}>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
</Chip>
);
}
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
switch (item.action) {
case "create":
return "success";
case "update":
return "info";
case "delete":
return "danger";
case "keep_local":
return item.selected ? "warning" : "default";
case "unchanged":
case "conflict":
case "ignored":
return "default";
}
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
@@ -701,7 +669,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "group"; label: string }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
@@ -743,7 +711,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" },
data: { kind: "group", label: "Variables" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
@@ -0,0 +1,271 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const trimmedPath = pathInput.trim();
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8!" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton
disabled={disabled || showPathInput}
onClick={() => setShowPathInput(true)}
>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{
label: "URL or file path",
icon: "globe",
disabled: showPathInput,
onSelect: () => setShowPathInput(true),
},
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="text-text underline underline-offset-2 hocus:text-primary disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function Onboarding() {
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, OpenAPI, or curl"
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
@@ -1,11 +1,11 @@
import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
interface Props {
hide: () => void;
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false);
const open = async (inNewWindow: boolean) => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
}
};
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
)}
<DialogFooter
inline
actions={[
{
label: "New Window",
rightSlot: <Icon icon="external_link" />,
onClick: () => open(true),
},
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
]}
/>
</VStack>
);
}
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { Button } from "./core/Button";
import { DialogFooter } from "./core/Dialog";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
@@ -91,6 +91,7 @@ function InitializedTemplateFunctionDialog({
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const formId = useId();
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({
return (
<form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
id={formId}
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full px-6">
<div className="overflow-y-auto h-full">
{name === "secure" ? (
<PlainInput
required
@@ -188,9 +190,8 @@ function InitializedTemplateFunctionDialog({
/>
)}
</div>
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
@@ -246,17 +247,14 @@ function InitializedTemplateFunctionDialog({
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</form>
);
}
@@ -272,7 +270,6 @@ TemplateFunctionDialog.show = (
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description,
render: ({ hide }) => {
+32
View File
@@ -0,0 +1,32 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
color?: Color | "default";
className?: string;
children: ReactNode;
}
/** A small tinted label, like a Gmail or Trello label */
export function Chip({ color = "default", className, children }: Props) {
return (
<span
className={classNames(
className,
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
"inline-flex items-center gap-1",
color === "default" && "text-text-subtle",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
color === "info" && "text-info",
color === "success" && "text-success",
color === "notice" && "text-notice",
color === "warning" && "text-warning",
color === "danger" && "text-danger",
)}
>
{children}
</span>
);
}
+17 -12
View File
@@ -1,9 +1,8 @@
import type { Color } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useState } from "react";
import { useId, useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "./Button";
import { DialogFooter } from "./Dialog";
import { PlainInput } from "./PlainInput";
export interface ConfirmProps {
@@ -22,6 +21,7 @@ export function Confirm({
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>("");
const formId = useId();
const handleHide = () => {
onResult(false);
onHide();
@@ -38,7 +38,7 @@ export function Confirm({
};
return (
<form className="flex flex-col" onSubmit={handleSuccess}>
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
{requireTyping && (
<PlainInput
autoFocus
@@ -61,14 +61,19 @@ export function Confirm({
}
/>
)}
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button autoFocus={!requireTyping} type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleHide },
{
label: confirmText ?? "Confirm",
color,
form: formId,
disabled: !didConfirm,
autoFocus: !requireTyping,
},
]}
/>
</form>
);
}
+73 -4
View File
@@ -1,9 +1,11 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, Overlay } from "@yaakapp-internal/ui";
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button, type ButtonProps } from "./Button";
import { IconButton } from "./IconButton";
export interface DialogProps {
@@ -38,6 +40,7 @@ export function Dialog({
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
@@ -71,7 +74,7 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
@@ -108,9 +111,11 @@ export function Dialog({
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
{children}
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
@@ -129,3 +134,67 @@ export function Dialog({
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
export interface DialogFooterAction {
label: string;
onClick?: () => void;
/** Submit the form with this id, which can be anywhere in the dialog body */
form?: string;
color?: ButtonProps["color"];
variant?: ButtonProps["variant"];
disabled?: boolean;
isLoading?: boolean;
autoFocus?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
/**
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
* dialogs whose body never scrolls.
*/
export function DialogFooter({
actions,
leftSlot,
inline,
}: {
actions: DialogFooterAction[];
leftSlot?: ReactNode;
inline?: boolean;
}) {
const slot = useContext(DialogFooterSlot);
const footer = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
@@ -0,0 +1,123 @@
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { forwardRef, type ReactNode } from "react";
import { Dropdown, type DropdownItem } from "./Dropdown";
import { IconButton } from "./IconButton";
export interface PathListItem {
path: string;
icon: IconProps["icon"];
/** Replaces the parent-path line under the name */
subtitle?: ReactNode;
failed?: boolean;
}
interface Props {
items: PathListItem[];
disabled?: boolean;
onRemove: (path: string) => void;
addItems: DropdownItem[];
addLabel: string;
addHint?: string;
hovering?: boolean;
/** Rows rendered between the items and the add row, such as an inline input */
children?: ReactNode;
}
/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */
export const PathList = forwardRef<HTMLDivElement, Props>(function PathList(
{ items, disabled, onRemove, addItems, addLabel, addHint, hovering, children },
ref,
) {
return (
<div
ref={ref}
className={classNames(
"flex flex-col gap-1.5 rounded-lg -m-1.5 p-1.5",
hovering && "bg-surface-highlight",
)}
>
{items.map((item) => {
const { name, parent } = splitPath(item.path);
return (
<div
key={item.path}
className={classNames(
"flex items-center gap-3 rounded-md pl-3 pr-1.5 py-2",
item.failed ? "bg-danger/10" : "bg-surface-highlight/50",
)}
>
<Icon
icon={item.icon}
className={classNames("shrink-0", item.failed ? "text-danger" : "text-text-subtle")}
/>
<div className="min-w-0 flex-1" title={item.path}>
<div className="truncate text-sm font-semibold">{name}</div>
<div
className={classNames(
"flex items-center gap-1.5 truncate text-xs",
item.failed ? "text-danger" : "text-text-subtlest",
)}
>
{item.subtitle ??
(parent !== "" && <span className="truncate font-mono">{parent}</span>)}
</div>
</div>
<IconButton
size="xs"
icon="x"
title={`Remove ${name}`}
disabled={disabled}
className="shrink-0"
onClick={() => onRemove(item.path)}
/>
</div>
);
})}
{children}
<Dropdown items={addItems}>
<button
type="button"
disabled={disabled}
className={classNames(
"h-sm w-full flex items-center gap-2 rounded-md border border-dashed px-3 text-sm text-text-subtle",
"hocus:border-border-focus hocus:text-text disabled:opacity-disabled",
hovering ? "border-notice" : "border-border",
)}
>
<Icon icon="plus" size="sm" className="text-text-subtlest" />
{hovering ? (
"Drop to add"
) : (
<>
<span>{addLabel}</span>
{addHint != null && (
<span className="ml-auto text-xs text-text-subtlest">{addHint}</span>
)}
</>
)}
</button>
</Dropdown>
</div>
);
});
/** The last segment as the name and everything before it, shortened, as the parent. */
export function splitPath(path: string): { name: string; parent: string } {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
try {
const url = new URL(path);
const segments = url.pathname.split("/").filter(Boolean);
const name = segments.at(-1) ?? url.host;
const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : "";
return { name, parent: parent.replace(/\/$/, "") };
} catch {
return { name: path, parent: "" };
}
}
const parts = path.split(/[/\\]/).filter(Boolean);
const name = parts.at(-1) ?? path;
const dirs = parts.slice(0, -1);
const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/");
return { name, parent };
}
+9 -14
View File
@@ -1,10 +1,9 @@
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { DynamicForm } from "../DynamicForm";
import { Button } from "./Button";
import { DialogFooter } from "./Dialog";
export interface PromptProps {
inputs: FormInput[];
@@ -48,19 +47,15 @@ export function Prompt({
const id = `prompt.form.${useRef(generateId()).current}`;
return (
<form
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit}
>
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
{cancelText || "Cancel"}
</Button>
<Button type="submit" color="primary">
{confirmText || "Done"}
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: cancelText || "Cancel", onClick: onCancel },
{ label: confirmText || "Done", color: "primary", form: id },
]}
/>
</form>
);
}
@@ -1,43 +0,0 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { Button } from "../core/Button";
import { Select } from "../core/Select";
interface Props {
branches: string[];
onCancel: () => void;
onSelect: (branch: string) => void;
selectText: string;
}
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
const [branch, setBranch] = useState<string>("__NONE__");
return (
<VStack
className="mb-4"
as="form"
space={4}
onSubmit={(e) => {
e.preventDefault();
onSelect(branch);
}}
>
<Select
name="branch"
hideLabel
label="Branch"
value={branch}
options={branches.map((b) => ({ label: b, value: b }))}
onChange={setBranch}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
Cancel
</Button>
<Button type="submit" color="primary">
{selectText}
</Button>
</HStack>
</VStack>
);
}
@@ -1,4 +1,4 @@
import type { GitStatusEntry } from "@yaakapp-internal/git";
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
import { useGit } from "@yaakapp-internal/git";
import type {
Environment,
@@ -9,8 +9,7 @@ import type {
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { type ComponentProps, useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName";
@@ -19,6 +18,7 @@ import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import { Chip } from "../core/Chip";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
@@ -333,16 +333,7 @@ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
)}
</>
);
@@ -376,16 +367,9 @@ function ExternalTreeNode({
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{displayPath}</div>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
entry.status === "modified" && "text-info",
entry.status === "untracked" && "text-success",
entry.status === "removed" && "text-danger",
)}
>
<Chip className="ml-auto" color={statusColor(entry.status)}>
{entry.status}
</InlineCode>
</Chip>
</div>
}
/>
@@ -479,3 +463,20 @@ function DiffPanel({
</div>
);
}
function statusColor(status: GitStatus): ComponentProps<typeof Chip>["color"] {
switch (status) {
case "modified":
return "info";
case "untracked":
return "success";
case "removed":
return "danger";
case "conflict":
return "warning";
case "current":
case "renamed":
case "type_change":
return "default";
}
}
+15 -15
View File
@@ -1,8 +1,8 @@
import type { DivergedStrategy } from "@yaakapp-internal/git";
import { HStack, InlineCode } from "@yaakapp-internal/ui";
import { InlineCode } from "@yaakapp-internal/ui";
import { useState } from "react";
import { showDialog } from "../../lib/dialog";
import { Button } from "../core/Button";
import { DialogFooter } from "../core/Dialog";
import { RadioCards } from "../core/RadioCards";
type Resolution = "force_reset" | "merge";
@@ -34,7 +34,7 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
};
return (
<div className="flex flex-col gap-4 mb-4">
<div className="flex flex-col gap-4">
<p className="text-text-subtle">
Your local branch has diverged from{" "}
<InlineCode>
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === "force_reset" ? "danger" : "primary"}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : "Select an option"}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleCancel },
{
label: selected != null ? resolutionLabel[selected] : "Select an option",
color: selected === "force_reset" ? "danger" : "primary",
disabled: selected == null,
onClick: handleSubmit,
},
]}
/>
</div>
);
}
-1
View File
@@ -23,7 +23,6 @@ export function useExportData() {
id: "export-data",
title: "Export Data",
size: "md",
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
+3
View File
@@ -0,0 +1,3 @@
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+1 -4
View File
@@ -1,4 +1,5 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { errorMessage } from "./errorMessage";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
@@ -45,7 +46,3 @@ export function tryBuildIntrospectionFromFile(
};
}
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+15 -9
View File
@@ -8,7 +8,7 @@ import {
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import type { ImportSourcePath } from "../components/ImportSourceList";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
@@ -20,10 +20,17 @@ import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planFile = (filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
const planUrl = (url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination });
const planSources = (sources: ImportSourcePath[], destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", {
filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path),
urls: sources.filter((source) => source.kind === "url").map((source) => source.path),
destination,
});
const detectSource = (source: ImportSourcePath) =>
rpc<string>(
"cmd_detect_import_source",
source.kind === "url" ? { url: source.path } : { filePath: source.path },
);
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
@@ -43,11 +50,11 @@ export const importData = createFastMutation({
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "lg",
className: "h-[36rem]",
disableClose: true,
render: ({ hide }) => {
const cancel = () => {
@@ -68,9 +75,8 @@ export const importData = createFastMutation({
<ImportDataDialog
currentWorkspace={currentWorkspace}
workspaces={workspaces}
selectedFolder={selectedFolder}
planFile={planFile}
planUrl={planUrl}
planSources={planSources}
detectSource={detectSource}
listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
+4 -4
View File
@@ -4,7 +4,7 @@ description: >
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
requests for reuse or manual testing, configure auth on saved requests, import
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
an OpenAPI spec, a Postman, Insomnia, or Bruno collection, or a cURL command, or run
saved requests across environments. Prefer over one-off `curl` when requests
should be saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
@@ -98,9 +98,9 @@ re-run it after acting.
## Core workflows
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
by hand every time.
**Start from a spec when one exists.** `yaak import <path>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests
by hand every time. Bruno collections can also be imported from a ZIP or directory.
**Make the host swappable.** Put the base URL in a base-environment variable,
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
+2 -2
View File
@@ -45,7 +45,7 @@ pub enum Commands {
/// Authentication commands
Auth(AuthArgs),
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Bruno, Swagger, or cURL
Import(ImportArgs),
/// Export Yaak workspace data
@@ -202,7 +202,7 @@ pub struct SendArgs {
#[derive(Args)]
pub struct ImportArgs {
/// Path to the file to import
/// Path to the file, ZIP archive, or directory to import
pub file: PathBuf,
/// Existing workspace ID to import into when supported by the importer
@@ -2,13 +2,12 @@ use crate::cli::{ExportArgs, ImportArgs};
use crate::context::CliContext;
use crate::utils::workspace::resolve_workspace_id;
use std::fs;
use std::io::ErrorKind;
use yaak_models::export::{self, ExportDataParams};
use yaak::import;
use yaak_models::export::{self, ExportDataParams};
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -51,11 +50,12 @@ async fn import(
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
let file_contents = read_import_file(&args.file)?;
let input = ImportRequest::from_path(&args.file)
.map_err(|err| format!("Unable to read import source {}: {err}", args.file.display()))?;
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
let plugin_manager = ctx.plugin_manager();
let import_result = plugin_manager
.import_data(&plugin_context, &file_contents)
.import_input(&plugin_context, &input)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
@@ -212,19 +212,6 @@ fn resolve_export_workspace_ids(
Ok(workspace_ids)
}
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
fs::read_to_string(path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
format!(
"Import file must be UTF-8 text; binary files are not supported: {}",
path.display()
)
} else {
format!("Unable to read import file {}: {err}", path.display())
}
})
}
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|| resources.environments.iter().any(|e| {
+91 -33
View File
@@ -1,46 +1,89 @@
use crate::PluginContextExt;
use crate::error::{Error, Result};
use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use std::path::Path;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
use yaak_plugins::MAX_IMPORT_BYTES;
use yaak_plugins::events::ImportRequest;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
origin: Option<ImportOrigin>,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
let input = read_import_file(file_path)?;
let plan =
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
plan_import_contents(window, &input, ImportDestination::NewWorkspace, origin).await?;
commit_import(window, plan)
}
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
file_paths: &[String],
urls: &[String],
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let plugin_context = window.plugin_context();
let mut seen = std::collections::BTreeSet::new();
let mut inputs = Vec::new();
for path in file_paths {
let origin = file_origin(path);
if !seen.insert(origin.origin.clone()) {
continue;
}
let input = read_import_file(path)?;
let response = plugin_manager
.import_input(&plugin_context, &input)
.await
.map_err(|err| Error::GenericError(format!("Unable to import {path}: {err}")))?;
inputs.push((response, origin));
}
for url in urls {
let url = normalize_import_url(url)?;
if !seen.insert(url.clone()) {
continue;
}
let input = fetch_import_url(window, &url).await?;
let response = plugin_manager
.import_input(&plugin_context, &input)
.await
.map_err(|err| Error::GenericError(format!("Unable to import {url}: {err}")))?;
inputs.push((response, url_origin(&url)));
}
Ok(import::plan_import_batch_resources(&window.db_manager(), destination, inputs)?)
}
pub(crate) async fn plan_import_url<R: Runtime>(
/// Which importer claims a source, without planning it. Backs the per-source status in the
/// import dialog so unsupported files are flagged before preview.
pub(crate) async fn detect_import_source<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let url = normalize_import_url(url)?;
let contents = fetch_import_url(window, &url).await?;
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
file_path: Option<String>,
url: Option<String>,
) -> Result<String> {
let input = match (file_path, url) {
(Some(path), _) => read_import_file(&path)?,
(None, Some(url)) => fetch_import_url(window, &normalize_import_url(&url)?).await?,
(None, None) => return Err(Error::GenericError("Nothing to detect".into())),
};
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let response = plugin_manager.import_input(&window.plugin_context(), &input).await.map_err(
|err| match err {
yaak_plugins::error::Error::PluginErr(msg) if msg.starts_with("No importers found") => {
Error::GenericError("Not a supported format".into())
}
err => Error::GenericError(err.to_string()),
},
)?;
Ok(response.importer)
}
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
input: &ImportRequest,
destination: ImportDestination,
origin: Option<ImportOrigin>,
) -> Result<ImportPlan> {
@@ -53,7 +96,7 @@ async fn plan_import_contents<R: Runtime>(
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
destination,
contents,
input,
origin,
})
.await?)
@@ -88,7 +131,10 @@ pub(crate) fn commit_import<R: Runtime>(
/// 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> {
async fn fetch_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<ImportRequest> {
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)?
@@ -104,10 +150,27 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
}
response
.text()
// Apply the same input-size limit to downloads before allocating the whole body.
let mut response = response;
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))?
{
if (bytes.len() + chunk.len()) as u64 > MAX_IMPORT_BYTES {
return Err(Error::GenericError("Import file exceeds the 64 MiB limit".into()));
}
bytes.extend_from_slice(&chunk);
}
let name = reqwest::Url::parse(&url)
.ok()
.and_then(|url| {
url.path_segments().and_then(|mut segments| segments.next_back().map(str::to_owned))
})
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "download".into());
ImportRequest::from_bytes(&name, &bytes).map_err(|err| Error::GenericError(err.to_string()))
}
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
@@ -128,15 +191,9 @@ pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
}
}
fn read_import_file(file_path: &str) -> Result<String> {
read_to_string(file_path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
Error::GenericError(format!(
"Import file must be UTF-8 text; binary files are not supported: {file_path}"
))
} else {
Error::GenericError(format!("Unable to read import file {file_path}: {err}"))
}
fn read_import_file(file_path: &str) -> Result<ImportRequest> {
ImportRequest::from_path(Path::new(file_path)).map_err(|err| {
Error::GenericError(format!("Unable to read import source {file_path}: {err}"))
})
}
@@ -147,7 +204,7 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn read_import_file_returns_error_for_binary_file() {
fn read_import_file_accepts_binary_file() {
let path = std::env::temp_dir().join(format!(
"yaak-import-binary-{}.pftrace",
SystemTime::now()
@@ -157,10 +214,11 @@ mod tests {
));
write(&path, [0xff, 0xfe, 0xfd]).expect("write binary fixture");
let err = read_import_file(path.to_str().expect("temp path is utf-8"))
.expect_err("binary import should return an error");
let input = read_import_file(path.to_str().expect("temp path is utf-8"))
.expect("binary import should preserve bytes");
assert!(err.to_string().contains("binary files are not supported"));
assert!(input.source.is_some());
assert!(input.content.is_empty());
remove_file(path).expect("remove binary fixture");
}
+4 -11
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::import::{commit_import, plan_import_data};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -1040,18 +1040,11 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
file_paths: &[String],
urls: &[String],
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
plan_import_data(&window, file_paths, urls, destination).await
}
async fn cmd_commit_import<R: Runtime>(
+9 -5
View File
@@ -517,11 +517,8 @@ async fn cmd_import_data<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportDataReq,
) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_paths, &req.urls, req.destination)
.await?)
}
async fn cmd_commit_import<R: Runtime>(
@@ -539,6 +536,13 @@ async fn cmd_list_import_sources<R: Runtime>(
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
}
async fn cmd_detect_import_source<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDetectImportSourceReq,
) -> Result<String> {
Ok(crate::import::detect_import_source(&ctx.window, req.file_path, req.url).await?)
}
async fn cmd_import_sources_for_origin<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportSourcesForOriginReq,
+4 -4
View File
@@ -45,6 +45,8 @@ export type CmdDeleteAllHttpResponsesReq = { requestId: string, };
export type CmdDeleteSendHistoryReq = { workspaceId: string, };
export type CmdDetectImportSourceReq = { filePath?: string, url?: string, };
export type CmdDisableEncryptionReq = { workspaceId: string, };
export type CmdDismissNotificationReq = { notificationId: string, };
@@ -145,12 +147,10 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
export type CmdImportDataReq = { filePath: string, destination: ImportDestination, };
export type CmdImportDataReq = { filePaths: Array<string>, urls: Array<string>, destination: ImportDestination, };
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
export type CmdImportUrlReq = { url: string, destination: ImportDestination, };
export type CmdListImportSourcesReq = { workspaceId: string, };
export type CmdMetadataReq = Record<string, never>;
@@ -256,6 +256,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
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<ServiceDefinition>], 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<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], 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, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], 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<GetThemesResponse>], 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<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], 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_workLine truncated
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<ServiceDefinition>], 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<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], 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, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], 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<GetThemesResponse>], 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<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], 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, GitBranchInfoLine truncated
export type WatchResult = { unlistenEvent: string, };
+12
View File
@@ -54,6 +54,10 @@ export type ImportPlan = {
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
/**
* Individual origins in a combined import, each retaining its own reimport history.
*/
sources: Array<ImportPlanSource>;
};
export type ImportPlanAction =
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanSource = {
importer: string;
origin: ImportOrigin;
workspaceId: string;
sourceKeys: { [key in string]?: string };
linkedSourceId?: string;
};
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
+13 -10
View File
@@ -228,15 +228,8 @@ pub struct CmdGetHttpResponseEventsReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub file_paths: Vec<String>,
pub urls: Vec<String>,
pub destination: ImportDestination,
}
@@ -264,6 +257,16 @@ pub struct CmdImportSourcesForOriginReq {
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdDetectImportSourceReq {
#[ts(optional)]
pub file_path: Option<String>,
#[ts(optional)]
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdHttpRequestActionsReq {}
@@ -939,10 +942,10 @@ macro_rules! with_commands {
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
cmd_detect_import_source(CmdDetectImportSourceReq) -> String,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+12
View File
@@ -54,6 +54,10 @@ export type ImportPlan = {
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
/**
* Individual origins in a combined import, each retaining its own reimport history.
*/
sources: Array<ImportPlanSource>;
};
export type ImportPlanAction =
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanSource = {
importer: string;
origin: ImportOrigin;
workspaceId: string;
sourceKeys: { [key in string]?: string };
linkedSourceId?: string;
};
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
+18 -1
View File
@@ -103,7 +103,7 @@ pub enum ImportDestination {
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
@@ -232,6 +232,19 @@ pub struct ImportPlanItem {
pub changed_fields: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanSource {
pub importer: String,
pub origin: ImportOrigin,
pub workspace_id: String,
pub source_keys: BTreeMap<String, String>,
#[serde(default)]
#[ts(optional)]
pub linked_source_id: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
@@ -251,6 +264,10 @@ pub struct ImportPlan {
#[serde(default)]
#[ts(optional)]
pub origin: Option<ImportOrigin>,
/// Individual origins in a combined import, each retaining its own reimport history.
#[serde(default)]
pub sources: Vec<ImportPlanSource>,
}
pub fn get_workspace_export_resources(
+10 -1
View File
@@ -470,7 +470,16 @@ export type HttpRequestAction = { label: string, icon?: Icon, };
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
export type ImportRequest = { content: string, };
/**
* Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
*/
export type ImportFileSource = { "type": "file", name: string, base64: string, } | { "type": "directory", path: string, };
export type ImportRequest = {
/**
* Kept for text-only importers and older runtimes.
*/
content: string, source?: ImportFileSource, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
+13
View File
@@ -240,7 +240,20 @@ pub struct ReloadResponse {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportRequest {
/// Kept for text-only importers and older runtimes.
pub content: String,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<ImportFileSource>,
}
/// Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_events.ts")]
pub enum ImportFileSource {
File { name: String, base64: String },
Directory { path: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
+79
View File
@@ -0,0 +1,79 @@
use crate::events::{ImportFileSource, ImportRequest};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::Path;
pub const MAX_IMPORT_BYTES: u64 = 64 * 1024 * 1024;
impl ImportRequest {
pub fn from_text(content: &str) -> Self {
Self { content: content.to_string(), source: None }
}
pub fn from_bytes(name: &str, bytes: &[u8]) -> io::Result<Self> {
if bytes.len() as u64 > MAX_IMPORT_BYTES {
return Err(io::Error::other("Import file exceeds the 64 MiB limit"));
}
Ok(Self {
content: std::str::from_utf8(bytes).unwrap_or_default().to_string(),
source: Some(ImportFileSource::File {
name: name.to_string(),
base64: STANDARD.encode(bytes),
}),
})
}
pub fn from_path(path: &Path) -> io::Result<Self> {
let path = fs::canonicalize(path)?;
let metadata = fs::metadata(&path)?;
if metadata.is_dir() {
return Ok(Self {
content: String::new(),
source: Some(ImportFileSource::Directory {
path: path
.to_str()
.ok_or_else(|| io::Error::other("Import path is not UTF-8"))?
.to_string(),
}),
});
}
if !metadata.is_file() {
return Err(io::Error::other("Import source must be a file or directory"));
}
let mut bytes = Vec::new();
File::open(&path)?.take(MAX_IMPORT_BYTES + 1).read_to_end(&mut bytes)?;
Self::from_bytes(path.file_name().and_then(|v| v.to_str()).unwrap_or("input"), &bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_and_binary_inputs_preserve_bytes() {
let input = ImportRequest::from_bytes("api.yml", "café".as_bytes()).unwrap();
assert_eq!(input.content, "café");
let input = ImportRequest::from_bytes("data.zip", &[0x50, 0x4b, 0xff]).unwrap();
assert_eq!(input.content, "");
let Some(ImportFileSource::File { name, base64 }) = input.source else {
panic!("file input")
};
assert_eq!(name, "data.zip");
assert_eq!(STANDARD.decode(base64).unwrap(), vec![0x50, 0x4b, 0xff]);
assert!(ImportRequest::from_text("text").source.is_none());
}
#[test]
fn directory_input_is_lazy_and_canonical() {
let input = ImportRequest::from_path(Path::new(".")).unwrap();
let Some(ImportFileSource::Directory { path }) = input.source else {
panic!("directory input")
};
assert_eq!(Path::new(&path), fs::canonicalize(".").unwrap());
assert!(input.content.is_empty());
assert!(ImportRequest::from_path(Path::new("does-not-exist-yaak-import")).is_err());
}
}
+2
View File
@@ -10,6 +10,8 @@ pub mod api;
mod checksum;
pub mod error;
pub mod events;
mod import_input;
pub use import_input::MAX_IMPORT_BYTES;
pub mod install;
pub mod manager;
pub mod native_template_functions;
+19 -4
View File
@@ -1091,18 +1091,32 @@ impl PluginManager {
&self,
plugin_context: &PluginContext,
content: &str,
) -> Result<ImportResponse> {
self.import_input(plugin_context, &ImportRequest::from_text(content)).await
}
pub async fn import_input(
&self,
plugin_context: &PluginContext,
input: &ImportRequest,
) -> Result<ImportResponse> {
let reply_events = self
.send_and_wait(
plugin_context,
&InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(),
}),
&InternalEventPayload::ImportRequest(input.clone()),
Duration::from_secs(60),
)
.await?;
// TODO: Don't just return the first valid response
let errors = reply_events
.iter()
.filter_map(|e| match &e.payload {
InternalEventPayload::ErrorResponse(error) => Some(error.error.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("; ");
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
@@ -1120,7 +1134,8 @@ impl PluginManager {
});
match result {
None => Err(PluginErr("No importers found for file contents".to_string())),
None if !errors.is_empty() => Err(PluginErr(errors)),
None => Err(PluginErr("No importers found for this input".to_string())),
Some(resp) => Ok(resp),
}
}
+625 -31
View File
@@ -12,10 +12,10 @@ use yaak_models::models::{
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{
BatchUpsertResult, ImportConflictResolution, ImportDestination, ImportOrigin, ImportPlan,
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanWarning, ImportResourceType,
UpdateSource,
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanSource, ImportPlanWarning,
ImportResourceType, UpdateSource,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::events::{ImportRequest, ImportResources, ImportResponse, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct PlanImportDataParams<'a> {
@@ -23,14 +23,14 @@ pub struct PlanImportDataParams<'a> {
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub destination: ImportDestination,
pub contents: &'a str,
pub input: &'a ImportRequest,
pub origin: Option<ImportOrigin>,
}
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
let import_result =
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
params.plugin_manager.import_input(params.plugin_context, params.input).await?;
plan_import_resources(
params.query_manager,
@@ -42,6 +42,157 @@ pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<Import
)
}
/// Plan each input independently so importer keys and linked-source matching remain scoped to
/// that input, then combine the results for one preview and one atomic commit.
pub fn plan_import_batch_resources(
query_manager: &QueryManager,
destination: ImportDestination,
inputs: Vec<(ImportResponse, ImportOrigin)>,
) -> Result<ImportPlan> {
let mut plans = Vec::new();
for (response, origin) in inputs {
let has_workspace = !response.resources.workspaces.is_empty();
let plan = plan_import_resources(
query_manager,
response.importer,
destination.clone(),
response.resources,
response.source_keys,
Some(origin),
)?;
plans.push((plan, has_workspace));
}
if plans.len() == 1 {
return Ok(plans.remove(0).0);
}
if plans.is_empty() {
return Err(yaak_models::error::Error::GenericError(
"Choose at least one import source".into(),
)
.into());
}
// Standalone environments/requests accompany the first actual collection, regardless of
// picker order. If every input is standalone, they share the first generated workspace.
let primary = plans.iter().position(|(_, has_workspace)| *has_workspace).unwrap_or(0);
let shared_workspace = plans[primary].0.resources.workspaces.first().cloned();
let mut taken_names =
query_manager.connect().list_workspaces()?.into_iter().map(|w| w.name).collect::<Vec<_>>();
let mut seen_items = BTreeSet::new();
let mut seen_linked_sources = BTreeSet::new();
let mut importers = Vec::new();
let mut combined = ImportPlan {
importer: String::new(),
destination,
resources: BatchUpsertResult::default(),
warnings: Vec::new(),
source_keys: BTreeMap::new(),
items: Vec::new(),
origin: None,
sources: Vec::new(),
};
for (index, (mut plan, has_workspace)) in plans.into_iter().enumerate() {
if let Some(workspace) = &shared_workspace
&& !has_workspace
&& index != primary
{
for old in plan.resources.workspaces.drain(..) {
plan.source_keys.remove(&old.id);
}
for v in &mut plan.resources.environments {
v.workspace_id = workspace.id.clone();
if v.parent_model == "workspace" {
v.parent_model = "environment".into();
v.name = format!("{} (Imported)", v.name);
}
}
for v in &mut plan.resources.folders {
v.workspace_id = workspace.id.clone();
}
for v in &mut plan.resources.http_requests {
v.workspace_id = workspace.id.clone();
}
for v in &mut plan.resources.grpc_requests {
v.workspace_id = workspace.id.clone();
}
for v in &mut plan.resources.websocket_requests {
v.workspace_id = workspace.id.clone();
}
plan.items = create_only_items(&plan);
}
for workspace in &mut plan.resources.workspaces {
workspace.name = unique_name(&workspace.name, &taken_names);
taken_names.push(workspace.name.clone());
}
for item in &plan.items {
if !seen_items.insert(item.model_id.clone()) {
return Err(yaak_models::error::Error::GenericError(
format!("More than one import source changes {}. Import these sources separately to resolve the overlap.", item.name)
).into());
}
}
if !importers.contains(&plan.importer) {
importers.push(plan.importer.clone());
}
let workspace_id = match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
ImportDestination::NewWorkspace => plan
.resources
.workspaces
.first()
.or(shared_workspace.as_ref())
.expect("planned workspace")
.id
.clone(),
};
if let Some(origin) = plan.origin {
let linked_source_id = match &plan.destination {
ImportDestination::NewWorkspace => None,
ImportDestination::ExistingWorkspace { .. } => match resolve_linked_source(
&query_manager.connect(),
&workspace_id,
&plan.importer,
&origin,
&plan.source_keys.values().cloned().collect(),
)? {
LinkedSource::Linked(source) => Some(source.id),
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
},
};
if let Some(id) = &linked_source_id
&& !seen_linked_sources.insert(id.clone())
{
return Err(yaak_models::error::Error::GenericError(
"More than one input matches the same linked import. Keep only one version of that source.".into()
).into());
}
combined.sources.push(ImportPlanSource {
importer: plan.importer,
origin,
workspace_id,
source_keys: plan.source_keys.clone(),
linked_source_id,
});
}
combined.resources.workspaces.extend(plan.resources.workspaces);
combined.resources.environments.extend(plan.resources.environments);
combined.resources.folders.extend(plan.resources.folders);
combined.resources.http_requests.extend(plan.resources.http_requests);
combined.resources.grpc_requests.extend(plan.resources.grpc_requests);
combined.resources.websocket_requests.extend(plan.resources.websocket_requests);
combined.items.extend(plan.items);
combined.source_keys.extend(plan.source_keys);
for warning in plan.warnings {
if !combined.warnings.contains(&warning) {
combined.warnings.push(warning);
}
}
}
combined.importer = importers.join(", ");
validate_plan(&combined)?;
Ok(combined)
}
/// Remap parsed importer resources into their selected destination.
///
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
@@ -294,32 +445,31 @@ pub fn plan_import_resources(
warnings,
items: Vec::new(),
origin,
sources: Vec::new(),
};
merge_with_linked_source(query_manager, &mut plan, &original)?;
warn_if_imported_elsewhere(&query_manager.connect(), &mut plan)?;
Ok(plan)
}
/// A document that already produced a workspace can be merged back into it. Importing it anywhere
/// else copies it instead, which is worth saying before the user finds two of everything.
/// Warn only when this importer has already read the exact same origin elsewhere. Similar
/// resource names are not evidence of a previous import; overlap matching belongs to merging
/// into an explicitly selected workspace.
fn warn_if_imported_elsewhere(db: &ClientDb, plan: &mut ImportPlan) -> Result<()> {
let Some(origin) = &plan.origin else {
return Ok(());
};
let destination_id = match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => Some(workspace_id.as_str()),
ImportDestination::NewWorkspace => None,
};
let incoming = plan.source_keys.values().collect::<BTreeSet<_>>();
let mut names = BTreeSet::new();
for workspace in db.list_workspaces()? {
if Some(workspace.id.as_str()) == destination_id {
continue;
}
for source in db.list_import_sources(&workspace.id)? {
let overlaps = db
.list_import_source_resources(&source.id)?
.iter()
.any(|row| incoming.contains(&row.source_key));
if overlaps {
if source.importer == plan.importer && source.origin == origin.origin {
names.insert(workspace.name.clone());
break;
}
@@ -508,10 +658,15 @@ fn record_import_source(
items: &BTreeMap<String, ImportPlanItem>,
upserted: &BatchUpsertResult,
) -> Result<()> {
if !plan.sources.is_empty() {
for source in &plan.sources {
record_import_plan_source(db, plan, source, items, upserted)?;
}
return Ok(());
}
let Some(origin) = &plan.origin else {
return Ok(());
};
let workspace_id = match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
ImportDestination::NewWorkspace => match upserted.workspaces.first() {
@@ -519,20 +674,50 @@ fn record_import_source(
None => return Ok(()),
},
};
record_import_plan_source(
db,
plan,
&ImportPlanSource {
importer: plan.importer.clone(),
origin: origin.clone(),
workspace_id,
source_keys: plan.source_keys.clone(),
linked_source_id: None,
},
items,
upserted,
)
}
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
let existing =
match resolve_linked_source(db, &workspace_id, &plan.importer, origin, &incoming_keys)? {
LinkedSource::Linked(source) => Some(source),
fn record_import_plan_source(
db: &WriteDb,
plan: &ImportPlan,
source: &ImportPlanSource,
items: &BTreeMap<String, ImportPlanItem>,
upserted: &BatchUpsertResult,
) -> Result<()> {
let incoming_keys: BTreeSet<String> = source.source_keys.values().cloned().collect();
let source_id = if plan.sources.is_empty() {
match resolve_linked_source(
db,
&source.workspace_id,
&source.importer,
&source.origin,
&incoming_keys,
)? {
LinkedSource::Linked(source) => Some(source.id),
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
}
} else {
source.linked_source_id.clone()
};
let import_source = db.upsert_import_source(
&ImportSource {
id: existing.map(|s| s.id).unwrap_or_default(),
workspace_id,
importer: plan.importer.clone(),
origin: origin.origin.clone(),
origin_label: origin.label.clone(),
id: source_id.unwrap_or_default(),
workspace_id: source.workspace_id.clone(),
importer: source.importer.clone(),
origin: source.origin.origin.clone(),
origin_label: source.origin.label.clone(),
last_imported_at: Utc::now().naive_utc(),
..Default::default()
},
@@ -560,7 +745,7 @@ fn record_import_source(
resource: ImportResourceType,
incoming: &dyn Fn() -> Result<Value>|
-> Result<()> {
let Some(source_key) = plan.source_keys.get(model_id) else {
let Some(source_key) = source.source_keys.get(model_id) else {
return Ok(());
};
let item = items.get(model_id);
@@ -919,7 +1104,7 @@ fn merge_with_linked_source(
let incoming = comparable(serde_json::to_value(&any)?);
let current = comparable(current_models.get(&planned_id).cloned().unwrap_or_default());
let (source_changed, local_changed) = match recorded_hash(row) {
let (source_changed, local_changed) = match recorded_hash(row, &incoming) {
Some(hash) => {
(hash_comparable(&incoming) != hash, hash_comparable(&current) != hash)
}
@@ -1087,6 +1272,12 @@ enum KeyStatus<'a> {
fn comparable(value: Value) -> Value {
let mut value = strip_ids(value);
if let Some(object) = value.as_object_mut() {
// Folder environment names are derived by upsert_environment, not source content.
if object.get("model").and_then(Value::as_str) == Some("environment")
&& object.get("parentModel").and_then(Value::as_str) == Some("folder")
{
object.remove("name");
}
for field in [
"model",
"workspaceId",
@@ -1118,7 +1309,11 @@ fn strip_ids(value: Value) -> Value {
}
}
const CONTENT_HASH_VERSION: &str = "v1:";
fn content_hash_version(value: &Value) -> &'static str {
// Only folder environments changed comparison semantics. Older hashes for these fall back
// to comparing the two sides until the next import records a hash without the derived name.
if value.get("parentModel").and_then(Value::as_str) == Some("folder") { "v2:" } else { "v1:" }
}
/// Identifies a resource's content well enough to tell "changed since the last import" from
/// "unchanged", without keeping a copy of every imported resource around.
@@ -1128,7 +1323,7 @@ fn content_hash(value: Value) -> String {
fn hash_comparable(value: &Value) -> String {
let canonical = serde_json::to_string(&sorted_keys(value.clone())).unwrap_or_default();
format!("{CONTENT_HASH_VERSION}{:x}", Sha256::digest(canonical.as_bytes()))
format!("{}{:x}", content_hash_version(value), Sha256::digest(canonical.as_bytes()))
}
/// The fields two comparable forms disagree on, so a preview row can explain itself.
@@ -1147,9 +1342,9 @@ fn changed_fields(incoming: &Value, current: &Value) -> Vec<String> {
}
/// A hash written by a version this build doesn't understand says nothing about the resource.
fn recorded_hash(row: &ImportSourceResource) -> Option<&str> {
fn recorded_hash<'a>(row: &'a ImportSourceResource, incoming: &Value) -> Option<&'a str> {
let hash = row.content_hash.as_deref()?;
hash.starts_with(CONTENT_HASH_VERSION).then_some(hash)
hash.starts_with(content_hash_version(incoming)).then_some(hash)
}
fn sorted_keys(value: Value) -> Value {
@@ -1312,7 +1507,15 @@ fn unique_name(name: &str, taken: &[String]) -> String {
if name.is_empty() || !taken.iter().any(|t| t == name) {
return name.to_string();
}
let mut n = 2;
// A suffix this function generated continues the sequence instead of nesting. A name that
// merely ends in a number, like "Release (2024)", has no taken base and keeps its name.
let (name, mut n) = match name.rsplit_once(" (") {
Some((base, rest)) => match rest.strip_suffix(')').and_then(|n| n.parse::<u32>().ok()) {
Some(n) if taken.iter().any(|t| t == base) => (base, n + 1),
_ => (name, 2),
},
None => (name, 2),
};
loop {
let candidate = format!("{name} ({n})");
if !taken.iter().any(|t| *t == candidate) {
@@ -1607,6 +1810,235 @@ mod tests {
}
}
fn batch_input(
importer: &str,
path: &str,
resources: ImportResources,
) -> (ImportResponse, ImportOrigin) {
(
ImportResponse { importer: importer.into(), resources, source_keys: None },
ImportOrigin { origin: path.into(), label: path.into() },
)
}
fn standalone_environment(name: &str) -> ImportResources {
ImportResources {
environments: vec![Environment {
id: "ev_export".into(),
model: "environment".into(),
workspace_id: "CURRENT_WORKSPACE".into(),
name: name.into(),
parent_model: "environment".into(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "host".into(),
value: "example.com".into(),
id: None,
}],
..Default::default()
}],
..Default::default()
}
}
fn collection_and_environments() -> Vec<(ImportResponse, ImportOrigin)> {
vec![
batch_input("Postman Environment", "/local.json", standalone_environment("Local")),
batch_input("Postman", "/collection.json", imported_resources()),
batch_input(
"Postman Environment",
"/production.json",
standalone_environment("Production"),
),
]
}
#[test]
fn batch_collection_and_environments_share_one_workspace_in_either_order() {
for reverse in [false, true] {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let mut inputs = collection_and_environments();
if reverse {
inputs.reverse();
}
let plan =
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, inputs)
.unwrap();
assert!(manager.connect().list_workspaces().unwrap().is_empty());
assert_eq!(plan.resources.workspaces.len(), 1);
let workspace_id = plan.resources.workspaces[0].id.clone();
assert_eq!(plan.resources.workspaces[0].name, "Imported");
assert_eq!(plan.resources.environments.len(), 3);
assert!(plan.resources.environments.iter().all(|e| e.workspace_id == workspace_id));
assert_eq!(plan.sources.len(), 3);
// The plan crosses IPC and is returned with the user's item selections.
let plan = serde_json::from_value(serde_json::to_value(plan).unwrap()).unwrap();
let committed = commit_import_plan(&manager, plan).unwrap();
assert_eq!(committed.workspaces.len(), 1);
assert_eq!(committed.environments.len(), 3);
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 3);
}
}
#[test]
fn batch_reimport_preserves_ids_and_scopes_deletions_to_each_source() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let first = plan_import_batch_resources(
&manager,
ImportDestination::NewWorkspace,
collection_and_environments(),
)
.unwrap();
let workspace_id = first.resources.workspaces[0].id.clone();
let ids =
first.resources.environments.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
commit_import_plan(&manager, first).unwrap();
let destination = ImportDestination::ExistingWorkspace {
workspace_id: workspace_id.clone(),
folder_id: None,
};
let unchanged = plan_import_batch_resources(
&manager,
destination.clone(),
collection_and_environments(),
)
.unwrap();
assert!(unchanged.items.iter().all(|i| i.action == ImportPlanAction::Unchanged));
assert_eq!(
unchanged.resources.environments.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>(),
ids
);
commit_import_plan(&manager, unchanged).unwrap();
let mut inputs = collection_and_environments();
inputs[1].0.resources.http_requests.remove(0);
let changed = plan_import_batch_resources(&manager, destination.clone(), inputs).unwrap();
let deletes = changed
.items
.iter()
.filter(|i| i.action == ImportPlanAction::Delete)
.collect::<Vec<_>>();
assert_eq!(deletes.len(), 1);
assert_eq!(deletes[0].name, "Root Request");
commit_import_plan(&manager, changed).unwrap();
assert_eq!(
manager
.connect()
.list_environments(&workspace_id)
.unwrap()
.iter()
.map(|v| v.id.clone())
.collect::<BTreeSet<_>>(),
ids
);
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 3);
// A companion file can still be reimported on its own after the batch.
let single = plan_import_batch_resources(
&manager,
destination,
vec![collection_and_environments().remove(0)],
)
.unwrap();
assert_eq!(single.items.len(), 1);
assert_eq!(single.items[0].action, ImportPlanAction::Unchanged);
}
#[test]
fn batch_same_named_environments_keep_distinct_source_links() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let inputs = || {
vec![
batch_input("Postman Environment", "/one.json", standalone_environment("Local")),
batch_input("Postman Environment", "/two.json", standalone_environment("Local")),
]
};
let first =
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, inputs())
.unwrap();
assert_eq!(first.resources.workspaces.len(), 1);
let workspace_id = first.resources.workspaces[0].id.clone();
commit_import_plan(&manager, first).unwrap();
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 2);
let next = plan_import_batch_resources(
&manager,
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
inputs(),
)
.unwrap();
assert_eq!(next.items.len(), 2);
assert!(next.items.iter().all(|i| i.action == ImportPlanAction::Unchanged));
}
#[test]
fn batch_separate_collections_keep_their_workspaces_and_unique_names() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let plan = plan_import_batch_resources(
&manager,
ImportDestination::NewWorkspace,
vec![
batch_input("Bruno", "/one", imported_resources()),
batch_input("Bruno", "/two", imported_resources()),
],
)
.unwrap();
assert_eq!(
plan.resources.workspaces.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(),
vec!["Imported", "Imported (2)"]
);
let committed = commit_import_plan(&manager, plan).unwrap();
for workspace in committed.workspaces {
assert_eq!(manager.connect().list_http_requests(&workspace.id).unwrap().len(), 2);
assert_eq!(manager.connect().list_import_sources(&workspace.id).unwrap().len(), 1);
}
}
#[test]
fn unique_name_continues_an_existing_suffix() {
let taken = vec!["Imported".to_string(), "Imported (2)".to_string()];
assert_eq!(unique_name("Imported", &taken), "Imported (3)");
assert_eq!(unique_name("Imported (2)", &taken), "Imported (3)");
assert_eq!(unique_name("Other (2)", &taken), "Other (2)");
let taken = vec!["Release (2024)".to_string()];
assert_eq!(unique_name("Release (2024)", &taken), "Release (2024) (2)");
}
#[test]
fn batch_rejects_two_versions_of_the_same_linked_collection() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let input = || batch_input("Postman", "/collection.json", imported_resources());
let first =
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, vec![input()])
.unwrap();
let workspace_id = first.resources.workspaces[0].id.clone();
commit_import_plan(&manager, first).unwrap();
let result = plan_import_batch_resources(
&manager,
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
vec![input(), input()],
);
assert!(result.unwrap_err().to_string().contains("More than one import source"));
}
#[test]
fn batch_commit_rolls_back_all_sources_if_linking_a_later_source_fails() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("models.sqlite");
let (manager, _blobs, _rx) =
yaak_models::init_standalone(&db_path, &dir.path().join("blobs.sqlite")).unwrap();
let plan = plan_import_batch_resources(
&manager,
ImportDestination::NewWorkspace,
collection_and_environments(),
)
.unwrap();
let workspace_id = plan.resources.workspaces[0].id.clone();
let connection = rusqlite::Connection::open(&db_path).unwrap();
connection.execute_batch("CREATE TRIGGER fail_source BEFORE INSERT ON import_sources WHEN NEW.origin = '/production.json' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;").unwrap();
drop(connection);
assert!(commit_import_plan(&manager, plan).is_err());
assert!(manager.connect().list_workspaces().unwrap().is_empty());
assert!(manager.connect().list_import_sources(&workspace_id).unwrap().is_empty());
}
#[test]
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
let (query_manager, _blob_manager, _rx) =
@@ -1795,6 +2227,66 @@ mod tests {
);
}
#[test]
fn duplicate_import_warning_requires_the_same_importer_and_origin() {
for origin in [
"/tmp/api.yaml",
"/tmp/bruno-collection",
"https://example.com/api.yaml",
] {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let original =
ImportOrigin { origin: origin.to_string(), label: "Original".to_string() };
let first = plan_import_resources(
&query_manager,
"Bruno".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
Some(importer_keys()),
Some(original.clone()),
)
.expect("plan first import");
commit_import_plan(&query_manager, first).expect("commit first import");
// Every case has the same resource keys, but only an exact origin/importer match
// proves this source was imported before. Display labels do not establish identity.
for (importer, incoming_origin, should_warn) in [
(
"Bruno",
Some(ImportOrigin { label: "Different label".to_string(), ..original.clone() }),
true,
),
(
"Bruno",
Some(ImportOrigin { origin: format!("{origin}-other"), ..original.clone() }),
false,
),
("OpenAPI", Some(original.clone()), false),
("Bruno", None, false),
] {
let plan = plan_import_resources(
&query_manager,
importer.to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
Some(importer_keys()),
incoming_origin.clone(),
)
.expect("plan another workspace");
assert_eq!(
plan.warnings.iter().any(|w| w.title.starts_with("Already imported")),
should_warn,
"importer {importer}, origin {incoming_origin:?}",
);
assert!(
plan.items.iter().all(|i| i.action == ImportPlanAction::Create),
"a new workspace never merges into an older import"
);
}
}
}
#[test]
fn environment_collisions_are_explicit_and_do_not_overwrite() {
let (query_manager, _blob_manager, _rx) =
@@ -2839,6 +3331,108 @@ mod tests {
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
}
#[test]
fn folder_environment_names_do_not_cause_reimport_updates() {
for legacy_hash in [false, true] {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.environments.push(Environment {
id: "ev_source_folder".to_string(),
name: "Folder Environment".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..resources.environments[0].clone()
});
let committed = first_import_with(&query_manager, resources.clone());
let workspace_id = &committed.workspaces[0].id;
let environment = committed
.environments
.iter()
.find(|e| e.parent_model == "folder")
.expect("folder environment");
assert_eq!(environment.name, "Imported Folder Environment");
if legacy_hash {
// Reproduce the pre-fix hash, which included the name generated on save.
let mut value = comparable(serde_json::to_value(environment).unwrap());
value["name"] = json!(environment.name);
let canonical = serde_json::to_string(&sorted_keys(value)).unwrap();
let hash = format!("v1:{:x}", Sha256::digest(canonical.as_bytes()));
let row = source_rows(&query_manager, workspace_id)
.into_iter()
.find(|r| r.model_id.as_deref() == Some(environment.id.as_str()))
.unwrap();
query_manager
.with_tx(|db| {
db.upsert_import_source_resource(&ImportSourceResource {
content_hash: Some(hash),
..row
})
})
.expect("write legacy hash");
let mut changed = resources.clone();
changed.environments[1].variables[0].value = "changed".to_string();
let plan = replan(&query_manager, workspace_id, changed);
assert_eq!(
item_by_name(&plan, "Folder Environment").action,
ImportPlanAction::Conflict,
"an old hash cannot attribute a real difference"
);
}
let plan = replan(&query_manager, workspace_id, resources.clone());
let item = item_by_name(&plan, "Folder Environment");
assert_eq!(item.action, ImportPlanAction::Unchanged);
assert!(item.changed_fields.is_empty());
commit_import_plan(&query_manager, plan).expect("record name-independent hash");
let mut changed = resources.clone();
changed.environments[1].variables[0].value = "changed".to_string();
let plan = replan(&query_manager, workspace_id, changed.clone());
let item = item_by_name(&plan, "Folder Environment");
assert_eq!(item.action, ImportPlanAction::Update);
assert_eq!(item.changed_fields, vec!["variables"]);
query_manager
.with_tx(|db| {
let mut local = db.get_environment(&environment.id)?;
local.variables[0].value = "local".to_string();
db.upsert_environment(&local, &UpdateSource::Background)
})
.expect("edit folder variables locally");
let plan = replan(&query_manager, workspace_id, resources);
assert_eq!(
item_by_name(&plan, "Folder Environment").action,
ImportPlanAction::KeepLocal
);
let plan = replan(&query_manager, workspace_id, changed);
assert_eq!(
item_by_name(&plan, "Folder Environment").action,
ImportPlanAction::Conflict
);
}
}
#[test]
fn base_and_named_environment_names_remain_import_content() {
for parent_model in ["workspace", "environment"] {
let original = Environment {
model: "environment".to_string(),
parent_model: parent_model.to_string(),
name: "Original".to_string(),
..Default::default()
};
let renamed = Environment { name: "Renamed".to_string(), ..original.clone() };
let original = comparable(serde_json::to_value(original).unwrap());
let renamed = comparable(serde_json::to_value(renamed).unwrap());
assert_eq!(changed_fields(&renamed, &original), vec!["name"]);
assert_ne!(hash_comparable(&original), hash_comparable(&renamed));
assert!(hash_comparable(&original).starts_with("v1:"));
}
}
#[test]
fn filling_in_editor_row_ids_is_not_an_edit() {
let (query_manager, _blob_manager, _rx) =
+103 -8
View File
@@ -33,6 +33,7 @@
"plugins/auth-oauth1",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-bruno",
"plugins/importer-curl",
"plugins/importer-insomnia",
"plugins/importer-openapi",
@@ -4574,11 +4575,19 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/nanoid": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/nanoid/-/nanoid-2.1.0.tgz",
"integrity": "sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "25.9.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz",
"integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -4667,12 +4676,28 @@
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
"integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC"
},
"node_modules/@usebruno/common": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@usebruno/common/-/common-0.25.0.tgz",
"integrity": "sha512-aTQtMuWTU3dL6brl5zwnmaoPeKStl6EK/frzwtVP4gmbqo+8Kjf7xafgYumrgOnL/lO1q34HqE00XCTEwXSmtw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
@@ -5084,6 +5109,10 @@
"resolved": "plugins-external/httpsnippet",
"link": true
},
"node_modules/@yaak/importer-bruno": {
"resolved": "plugins/importer-bruno",
"link": true
},
"node_modules/@yaak/importer-curl": {
"resolved": "plugins/importer-curl",
"link": true
@@ -5904,6 +5933,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arcsecond": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/arcsecond/-/arcsecond-5.0.0.tgz",
"integrity": "sha512-J/fHdyadnsIencRsM6oUSsraCKG+Ni9Udcgr/eusxjTzX3SEQtCUQSpP0YtImFPfIK6DdT1nqwN0ng4FqNmwgA==",
"license": "MIT"
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -9702,6 +9737,12 @@
"node": ">=4"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -11367,6 +11408,15 @@
"node": ">=12.20.0"
}
},
"node_modules/ohm-js": {
"version": "16.6.0",
"resolved": "https://registry.npmjs.org/ohm-js/-/ohm-js-16.6.0.tgz",
"integrity": "sha512-X9P4koSGa7swgVQ0gt71UCYtkAQGOjciJPJAz74kDxWt8nXbH5HrDOQG6qBDH7SR40ktNv4x61BwpTDE9q4lRA==",
"license": "MIT",
"engines": {
"node": ">=0.12.1"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -11719,7 +11769,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
@@ -13660,7 +13709,6 @@
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -14769,9 +14817,9 @@
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
@@ -14787,7 +14835,6 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
"integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
@@ -14913,11 +14960,13 @@
"packages/plugin-runtime": {
"name": "@yaakapp-internal/plugin-runtime",
"dependencies": {
"ws": "^8.21.0"
"ws": "^8.21.0",
"yauzl": "^3.4.0"
},
"devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13",
"@types/yauzl": "^3.4.0",
"esbuild": "^0.28.0"
}
},
@@ -15116,6 +15165,52 @@
"xpath": "^0.0.34"
}
},
"plugins/importer-bruno": {
"name": "@yaak/importer-bruno",
"version": "0.1.0",
"dependencies": {
"@usebruno/filestore": "^0.12.0",
"yaml": "^2.8.3"
}
},
"plugins/importer-bruno/node_modules/@usebruno/filestore": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@usebruno/filestore/-/filestore-0.12.0.tgz",
"integrity": "sha512-MgVuM6H+qXxipPJ+T3SaIdWoTn5OTq5QaMQHVQZ3GUByj78GPgWiflqfQXGJTLF8AAQFNNgMkCOU/36hv1Lw6g==",
"license": "MIT",
"dependencies": {
"@types/nanoid": "^2.1.0",
"@usebruno/common": "0.25.0",
"@usebruno/lang": "0.39.0",
"ajv": "^8.17.1",
"lodash": "4.18.1",
"yaml": "^2.3.4"
}
},
"plugins/importer-bruno/node_modules/@usebruno/lang": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/@usebruno/lang/-/lang-0.39.0.tgz",
"integrity": "sha512-D/TWoJYhXvC4CjI7OsZCm8zVRel7so9BiktMXmdpKbvF2b1bin33aO71hn9K38pypsBicl5Y8CK8RJ0QXo1qBg==",
"license": "MIT",
"dependencies": {
"arcsecond": "^5.0.0",
"dotenv": "^16.3.1",
"lodash": "4.18.1",
"ohm-js": "^16.6.0"
}
},
"plugins/importer-bruno/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"plugins/importer-curl": {
"name": "@yaak/importer-curl",
"version": "0.1.0",
+1
View File
@@ -32,6 +32,7 @@
"plugins/auth-oauth1",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-bruno",
"plugins/importer-curl",
"plugins/importer-insomnia",
"plugins/importer-openapi",
+1 -1
View File
@@ -288,10 +288,10 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
"Importing from a file needs a filesystem, which a browser tab has no",
"localFiles",
],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
cmd_detect_import_source: ["Importing isn't available in the browser yet", null],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
@@ -0,0 +1,40 @@
# Importers
Implement **`onImportSource(ctx, { source })`**. The source is one of two things:
- `{ type: "text", name, text }`: a single UTF-8 document, or pasted text (named `input`).
- `{ type: "directory", files }`: a directory or a ZIP archive, read as one file tree.
Return `null` or `undefined` when the source isn't your format. Only one importer's result is used per source.
```ts
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
importer: {
name: "Example",
async onImportSource(ctx, { source }) {
if (source.type === "text") return convertDocument(source.text);
for (const entry of await source.files.readDir()) {
if (entry.name === "example.json") {
return convertDocument(await source.files.readTextFile(entry.path));
}
}
return null;
},
},
};
```
The older `onImport(ctx, { text })` hook still works but is deprecated. It only receives single text documents and is ignored when `onImportSource` is defined.
## The file tree
- `files.kind` is `"directory"` or `"zip"`; `files.name` is the selected basename.
- `readDir(path = "")` lists immediate children. `readFile(path)` returns bytes. `readTextFile(path)` decodes UTF-8 strictly.
- Paths use `/` and are relative to the root. Absolute paths, `..`, and backslashes are rejected. Symbolic links are skipped in listings and rejected on read. Nothing is extracted to disk.
- ZIPs are detected by signature, not extension. A ZIP made by compressing a folder is rooted at that folder, so plugins see the same tree as for the directory. Directory reads are not a snapshot: a symlink swapped in under a validated path can redirect a read outside the root, so treat directory imports as trusted input.
- Limits: 64 MiB per source file or download, 32 MiB per file read, 10,000 entries, 256 MiB declared ZIP expansion. Encrypted ZIPs are unsupported.
- The tree is read-only and only valid until the hook settles.
Plugins run with the normal Node.js permissions of the plugin runtime; the tree scopes convenience, not security.
+10 -1
View File
@@ -470,7 +470,16 @@ export type HttpRequestAction = { label: string, icon?: Icon, };
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
export type ImportRequest = { content: string, };
/**
* Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
*/
export type ImportFileSource = { "type": "file", name: string, base64: string, } | { "type": "directory", path: string, };
export type ImportRequest = {
/**
* Kept for text-only importers and older runtimes.
*/
content: string, source?: ImportFileSource, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
+7 -1
View File
@@ -6,4 +6,10 @@ export * from "./bindings/gen_events";
// Some extras for utility
export type { PartialImportResources } from "./plugins/ImporterPlugin";
export type {
PartialImportResources,
ImportFiles,
ImportFileEntry,
ImportSource,
ImportSourceHandler,
} from "./plugins/ImporterPlugin";
@@ -21,11 +21,45 @@ export type ImportPluginResponse =
resources: PartialImportResources;
});
/** A read-only view of a selected directory or ZIP archive. Paths are relative and use
* forward slashes. Directory path checks are best-effort under concurrent filesystem
* changes, not a security boundary. Valid only while onImportSource runs. */
export interface ImportFiles {
readonly name: string;
readonly kind: "zip" | "directory";
/** List immediate children. The root directory is "" (the default). */
readDir(path?: string): Promise<ImportFileEntry[]>;
readFile(path: string): Promise<Uint8Array>;
/** Decode UTF-8 strictly; binary or invalid UTF-8 content throws. */
readTextFile(path: string): Promise<string>;
}
export interface ImportFileEntry {
name: string;
path: string;
type: "file" | "directory";
}
/** What the user chose to import: one text document, or a directory or ZIP read as a tree. */
export type ImportSource =
| { type: "text"; name: string; text: string }
| { type: "directory"; files: ImportFiles };
type ImportTextHandler = (
ctx: Context,
args: { text: string },
) => MaybePromise<ImportPluginResponse | undefined>;
export type ImportSourceHandler = (
ctx: Context,
args: { source: ImportSource },
) => MaybePromise<ImportPluginResponse | undefined>;
export type ImporterPlugin = {
name: string;
description?: string;
onImport(
ctx: Context,
args: { text: string },
): MaybePromise<ImportPluginResponse | null | undefined>;
/** Return null when the source isn't this importer's format. Takes precedence over onImport. */
onImportSource?: ImportSourceHandler;
/** @deprecated Use onImportSource. Only receives single text documents. */
onImport?: ImportTextHandler;
};
+3 -1
View File
@@ -6,11 +6,13 @@
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
},
"dependencies": {
"ws": "^8.21.0"
"ws": "^8.21.0",
"yauzl": "^3.4.0"
},
"devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13",
"@types/yauzl": "^3.4.0",
"esbuild": "^0.28.0"
}
}
+14 -13
View File
@@ -51,6 +51,7 @@ import type {
} from "@yaakapp-internal/plugins";
import { applyDynamicFormInput } from "./common";
import { EventChannel } from "./EventChannel";
import { runImporter } from "./importFiles";
import { migrateTemplateFunctionSelectOptions } from "./migrations";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
@@ -157,19 +158,14 @@ export class PluginInstance {
return;
}
if (
payload.type === "import_request" &&
typeof this.#mod?.importer?.onImport === "function"
) {
const reply = await this.#mod.importer.onImport(ctx, {
text: payload.content,
});
if (payload.type === "import_request" && this.#mod?.importer != null) {
const reply = await runImporter(this.#mod.importer, ctx, payload);
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: "import_response",
importer: this.#mod.importer.name,
resources: reply.resources as ImportResources,
sourceKeys: reply.sourceKeys ?? null,
sourceKeys: reply.sourceKeys,
};
this.#sendPayload(context, replyPayload, replyId);
return;
@@ -649,10 +645,12 @@ export class PluginInstance {
complete: info.complete,
},
async (offset, length) => {
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
context,
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
);
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(context, {
type: "read_http_response_body_chunk_request",
responseId,
offset,
length,
});
return decodeBase64Chunk(chunk.data);
},
{ refresh: bodyInfo },
@@ -853,7 +851,10 @@ export class PluginInstance {
// carries the only copy of its body. A saved one is read back from
// the host like any other. Callers get the same thing either way.
if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
return {
httpResponse: forPlugin(httpResponse),
body: await storedBody(httpResponse.id),
};
}
const bytes = decodeBase64Chunk(body);
Binary file not shown.
@@ -0,0 +1,224 @@
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Context, ImportRequest, ImportSource } from "@yaakapp/api";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { createImportFiles, runImporter } from "./importFiles";
const dirs: string[] = [];
beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
const input = (name: string, data: Uint8Array): ImportRequest => ({
content: "",
source: { type: "file", name, base64: Buffer.from(data).toString("base64") },
});
async function zip(name = "import-files.zip") {
return input(name, await readFile(new URL(`./fixtures/${name}`, import.meta.url)));
}
const ctx = {} as Context;
describe("import file tree", () => {
test("exposes legacy text and single binary files through the same read API", async () => {
const session = await createImportFiles({ content: "café" });
expect(session.text).toBe("café");
expect(await session.files.readDir()).toEqual([{ name: "input", path: "input", type: "file" }]);
expect(await session.files.readTextFile("input")).toBe("café");
session.close();
await expect(session.files.readFile("input")).rejects.toThrow("closed");
const binary = await createImportFiles(input("body.bin", Uint8Array.from([255, 0, 128])));
expect(binary.isText).toBe(false);
expect([...(await binary.files.readFile("body.bin"))]).toEqual([255, 0, 128]);
await expect(binary.files.readTextFile("body.bin")).rejects.toThrow();
binary.close();
});
test("lists immediate ZIP children, including implicit/empty directories, and reads binary/UTF-8", async () => {
const session = await createImportFiles(await zip());
expect(session.files.kind).toBe("zip");
expect(session.text).toBe("");
expect(await session.files.readDir()).toEqual([
{ name: "binary.bin", path: "binary.bin", type: "file" },
{ name: "collection", path: "collection", type: "directory" },
]);
expect((await session.files.readDir("collection")).map((e) => e.name)).toEqual([
"café.yml",
"empty",
"nested",
]);
expect(await session.files.readDir("collection/empty")).toEqual([]);
expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
expect([...(await session.files.readFile("binary.bin"))]).toEqual([0, 255, 128]);
await expect(session.files.readFile("collection")).rejects.toThrow("not found");
await expect(session.files.readDir("missing")).rejects.toThrow("not found");
session.close();
});
test("a misleading ZIP suffix still dispatches as text", async () => {
const content = '{"openapi":"3.0.0","info":{"title":"Example","version":"1"},"paths":{}}';
const source = input("api.zip", Buffer.from(content));
const onImport = vi.fn().mockResolvedValue(null);
await runImporter({ name: "Text", onImport }, ctx, source);
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: content });
const onImportSource = vi.fn().mockResolvedValue(null);
await runImporter({ name: "Source", onImportSource }, ctx, source);
expect(onImportSource).toHaveBeenCalledExactlyOnceWith(ctx, {
source: { type: "text", name: "api.zip", text: content },
});
});
test("ZIP signatures take precedence over names, including empty archives", async () => {
const bytes = await readFile(new URL("./fixtures/import-files.zip", import.meta.url));
const empty = Buffer.alloc(22);
empty.writeUInt32LE(0x06054b50);
for (const data of [bytes, empty]) {
const source = input("collection.yaml", data);
const session = await createImportFiles(source);
try {
expect(session.files.kind).toBe("zip");
if (data === empty) expect(await session.files.readDir()).toEqual([]);
else expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
} finally {
session.close();
}
const onImport = vi.fn().mockResolvedValue(null);
expect(await runImporter({ name: "Text", onImport }, ctx, source)).toBeNull();
expect(onImport).not.toHaveBeenCalled();
}
});
test("a ZIP made by compressing a folder is rooted at that folder", async () => {
const session = await createImportFiles(await zip("wrapped.zip"));
try {
expect((await session.files.readDir()).map((e) => e.name)).toEqual(["nested", "root.txt"]);
expect(await session.files.readTextFile("nested/file.txt")).toBe("inner\n");
await expect(session.files.readDir("Demo")).rejects.toThrow("not found");
} finally {
session.close();
}
});
test("malformed input with a ZIP signature still fails archive validation", async () => {
const source = input("broken.zip", Buffer.from([0x50, 0x4b, 0x03, 0x04]));
await expect(createImportFiles(source)).rejects.toThrow();
});
test("directory view reads lazily and blocks links and escaping paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-"));
dirs.push(root);
await mkdir(path.join(root, "nested"));
await writeFile(path.join(root, "nested", "file"), "first");
await symlink(tmpdir(), path.join(root, "escape"));
const session = await createImportFiles({
content: "",
source: { type: "directory", path: root },
});
expect(await session.files.readDir()).toEqual([
{ name: "nested", path: "nested", type: "directory" },
]);
await writeFile(path.join(root, "nested", "file"), "updated");
expect(await session.files.readTextFile("nested/file")).toBe("updated");
await expect(session.files.readDir("escape")).rejects.toThrow("symbolic");
for (const p of [
"../outside",
"/absolute",
"C:/outside",
"nested/../../outside",
"nested\\file",
]) {
await expect(session.files.readFile(p)).rejects.toThrow();
}
session.close();
});
test.each(["import-traversal.zip", "import-duplicate.zip", "import-symlink.zip"])(
"rejects unsafe ZIP %s",
async (name) => {
await expect(createImportFiles(await zip(name))).rejects.toThrow();
},
);
test("ZIP entries are decompressed only on read, with a read-size limit", async () => {
const session = await createImportFiles(await zip("import-large.zip"));
expect((await session.files.readDir())[0]?.name).toBe("large.txt");
await expect(session.files.readFile("large.txt")).rejects.toThrow("32 MiB");
session.close();
});
test("the deprecated text hook preserves its arguments and skips other input", async () => {
const onImport = vi.fn().mockResolvedValue(null);
const importer = { name: "Legacy", onImport };
await runImporter(importer, ctx, { content: "original" });
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "original" });
onImport.mockClear();
await runImporter(importer, ctx, input("empty.txt", new Uint8Array()));
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "" });
onImport.mockClear();
for (const source of [
await zip(),
input("binary", Uint8Array.from([255])),
{
content: "",
source: { type: "directory", path: "/does-not-exist" },
} satisfies ImportRequest,
]) {
expect(await runImporter(importer, ctx, source)).toBeNull();
}
expect(onImport).not.toHaveBeenCalled();
});
test("the source hook gets text for documents and a tree for directories and ZIPs", async () => {
const onImport = vi.fn().mockResolvedValue(null);
const onImportSource = vi.fn().mockResolvedValue(null);
const importer = { name: "Both", onImport, onImportSource };
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-hooks-"));
dirs.push(root);
const cases: [ImportRequest, ImportSource["type"] | null, string?][] = [
[{ content: "pasted" }, "text", "pasted"],
[input("text.txt", Buffer.from("text")), "text", "text"],
[input("empty.txt", new Uint8Array()), "text", ""],
[input("binary", Uint8Array.from([255])), null],
[await zip(), "directory"],
[{ content: "", source: { type: "directory", path: root } }, "directory"],
];
for (const [request, type, text] of cases) {
onImportSource.mockClear();
expect(await runImporter(importer, ctx, request)).toBeNull();
if (type == null) {
expect(onImportSource).not.toHaveBeenCalled();
continue;
}
expect(onImportSource).toHaveBeenCalledOnce();
const { source } = onImportSource.mock.calls[0]![1] as { source: ImportSource };
expect(source.type).toBe(type);
if (source.type === "text") expect(source.text).toBe(text);
else await expect(source.files.readDir()).rejects.toThrow("closed");
}
onImportSource.mockResolvedValue(undefined);
await runImporter(importer, ctx, { content: "pasted" });
expect(onImport).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
});
test("closes file access even if the importer throws", async () => {
let saved: import("@yaakapp/api").ImportFiles | undefined;
await expect(
runImporter(
{
name: "Fails",
onImportSource(_ctx, { source }) {
if (source.type === "directory") saved = source.files;
throw new Error("failure");
},
},
ctx,
await zip(),
),
).rejects.toThrow("failure");
await expect(saved!.readDir()).rejects.toThrow("closed");
});
});
+326
View File
@@ -0,0 +1,326 @@
import { constants } from "node:fs";
import { lstat, open, opendir, realpath } from "node:fs/promises";
import path from "node:path";
import type { Context, ImportFileEntry, ImportFiles, ImportRequest } from "@yaakapp/api";
import type { ImporterPlugin, ImportSource } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import { type Entry, fromBuffer, type ZipFile } from "yauzl";
const MAX_FILE_BYTES = 32 * 1024 * 1024;
const MAX_SOURCE_BYTES = 64 * 1024 * 1024;
const MAX_ENTRIES = 10_000;
const MAX_EXPANDED_BYTES = 256 * 1024 * 1024;
function relativePath(value: string): string {
if (
value.includes("\\") ||
value.includes("\0") ||
value.startsWith("/") ||
/^[a-z]:/i.test(value)
) {
throw new Error(`Invalid import path: ${value}`);
}
const parts = value.split("/");
if (parts.includes("..")) throw new Error("Import paths cannot leave the selected source");
return parts.filter((p) => p !== "" && p !== ".").join("/");
}
function isZip(bytes: Buffer): boolean {
// Names are hints only: a text specification can legitimately be named *.zip.
return (
bytes[0] === 0x50 &&
bytes[1] === 0x4b &&
((bytes[2] === 3 && bytes[3] === 4) ||
(bytes[2] === 5 && bytes[3] === 6) ||
(bytes[2] === 7 && bytes[3] === 8))
);
}
export interface ImportFileSession {
files: ImportFiles;
kind: "file" | ImportFiles["kind"];
name: string;
text: string;
/** Whether text-only plugins can handle this input, including an empty text file. */
isText: boolean;
close(): void;
}
/** Construct one scoped view for one invocation. No ZIP entries are extracted to disk. */
export async function createImportFiles(input: ImportRequest): Promise<ImportFileSession> {
let closed = false;
let zip: ZipFile | undefined;
let zipError: Error | undefined;
const assertOpen = () => {
if (closed) throw new Error("Import source is closed");
if (zipError) throw zipError;
};
let kind: ImportFileSession["kind"] = "file";
let name = "input";
let text = input.content;
let isText = true;
let readDir: ImportFiles["readDir"];
let readFile: ImportFiles["readFile"];
if (input.source?.type === "directory") {
kind = "directory";
isText = false;
text = "";
const root = await realpath(input.source.path);
name = path.basename(root);
const resolve = async (value: string) => {
assertOpen();
const rel = relativePath(value);
let candidate = root;
// Reject links visible during validation, including intermediate components.
// These path checks are not atomic with open/opendir: concurrent filesystem
// changes can redirect a later operation. This is not a confinement boundary.
for (const component of rel.split("/").filter(Boolean)) {
candidate = path.join(candidate, component);
if ((await lstat(candidate)).isSymbolicLink())
throw new Error("Import sources do not follow symbolic links");
}
const canonical = await realpath(candidate);
const within = path.relative(root, canonical);
if (within === ".." || within.startsWith(`..${path.sep}`) || path.isAbsolute(within))
throw new Error("Import path is outside the selected source");
return canonical;
};
readDir = async (value = "") => {
const rel = relativePath(value);
const entries: ImportFileEntry[] = [];
for await (const entry of await opendir(await resolve(rel))) {
if (!entry.isFile() && !entry.isDirectory()) continue;
entries.push({
name: entry.name,
path: rel ? `${rel}/${entry.name}` : entry.name,
type: entry.isDirectory() ? "directory" : "file",
});
if (entries.length > MAX_ENTRIES)
throw new Error("Import directory contains too many entries");
}
assertOpen();
return entries.sort((a, b) => a.path.localeCompare(b.path));
};
readFile = async (value) => {
const handle = await open(
await resolve(value),
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
try {
const stat = await handle.stat();
if (!stat.isFile()) throw new Error("Import path is not a regular file");
if (stat.size > MAX_FILE_BYTES)
throw new Error("Import file exceeds the 32 MiB read limit");
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of handle.createReadStream({ autoClose: false })) {
const bytes = Buffer.from(chunk as Uint8Array);
total += bytes.length;
if (total > MAX_FILE_BYTES) throw new Error("Import file exceeds the 32 MiB read limit");
chunks.push(bytes);
}
assertOpen();
return Buffer.concat(chunks);
} finally {
await handle.close();
}
};
} else {
const source = input.source;
if (source && source.base64.length > Math.ceil(MAX_SOURCE_BYTES / 3) * 4)
throw new Error("Import source exceeds the 64 MiB limit");
const bytes = source ? Buffer.from(source.base64, "base64") : Buffer.from(input.content);
name = source?.name || "input";
if (bytes.length > MAX_SOURCE_BYTES) throw new Error("Import source exceeds the 64 MiB limit");
const entries = new Map<string, { type: "file" | "directory"; entry?: Entry; data?: Buffer }>([
["", { type: "directory" }],
]);
if (source && isZip(bytes)) {
kind = "zip";
text = "";
isText = false;
zip = await new Promise<ZipFile>((resolve, reject) => {
fromBuffer(
bytes,
{ lazyEntries: true, autoClose: false, strictFileNames: false, validateEntrySizes: true },
(err, result) => (err ? reject(err) : resolve(result)),
);
});
zip.on("error", (err: Error) => {
zipError = err;
});
const archive = zip;
try {
await new Promise<void>((resolve, reject) => {
let count = 0;
let expandedBytes = 0;
archive.once("error", reject);
archive.once("end", () => {
archive.removeListener("error", reject);
resolve();
});
archive.on("entry", (entry: Entry) => {
try {
if (++count > MAX_ENTRIES) throw new Error("ZIP contains too many entries");
expandedBytes += entry.uncompressedSize;
if (expandedBytes > MAX_EXPANDED_BYTES)
throw new Error("ZIP expanded size exceeds the 256 MiB limit");
if (entry.isEncrypted()) throw new Error("Encrypted ZIP entries are not supported");
const mode = (entry.externalFileAttributes >>> 16) & 0xf000;
if (mode === 0xa000) throw new Error("ZIP symbolic links are not supported");
const entryPath = relativePath(entry.fileName);
if (!entryPath) throw new Error("ZIP contains an empty entry path");
const type = entry.fileName.endsWith("/") ? "directory" : "file";
const previous = entries.get(entryPath);
if (previous && (type !== "directory" || previous.type !== "directory"))
throw new Error(`Duplicate ZIP path: ${entryPath}`);
entries.set(entryPath, { type, entry });
let parent = path.posix.dirname(entryPath);
while (parent !== ".") {
if (entries.get(parent)?.type === "file")
throw new Error(`ZIP file/directory conflict: ${parent}`);
if (!entries.has(parent)) entries.set(parent, { type: "directory" });
parent = path.posix.dirname(parent);
}
if (entries.size > MAX_ENTRIES) throw new Error("ZIP contains too many paths");
archive.readEntry();
} catch (err) {
reject(err);
}
});
archive.readEntry();
});
} catch (err) {
archive.close();
throw err;
}
unwrapSingleRoot(entries);
} else {
const entryPath = relativePath(name);
if (!entryPath || entryPath.includes("/"))
throw new Error("Single import files must have a basename");
entries.set(entryPath, { type: "file", data: bytes });
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
text = "";
isText = false;
}
}
readDir = async (value = "") => {
assertOpen();
const rel = relativePath(value);
if (entries.get(rel)?.type !== "directory")
throw new Error(`Import directory not found: ${rel}`);
return [...entries]
.filter(
([p]) => p !== "" && (path.posix.dirname(p) === "." ? "" : path.posix.dirname(p)) === rel,
)
.map(([p, entry]) => ({ name: path.posix.basename(p), path: p, type: entry.type }))
.sort((a, b) => a.path.localeCompare(b.path));
};
readFile = async (value) => {
assertOpen();
const found = entries.get(relativePath(value));
if (!found || found.type !== "file") throw new Error(`Import file not found: ${value}`);
if (found.data) {
if (found.data.length > MAX_FILE_BYTES)
throw new Error("Import file exceeds the 32 MiB read limit");
return Buffer.from(found.data);
}
const entry = found.entry!;
if (entry.uncompressedSize > MAX_FILE_BYTES)
throw new Error("Import file exceeds the 32 MiB read limit");
const stream = await new Promise<import("node:stream").Readable>((resolve, reject) => {
zip!.openReadStream(entry, (err, result) => (err ? reject(err) : resolve(result)));
});
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of stream) {
const bytes = Buffer.from(chunk as Uint8Array);
total += bytes.length;
if (total > MAX_FILE_BYTES) {
stream.destroy();
throw new Error("Import file exceeds the 32 MiB read limit");
}
chunks.push(bytes);
}
assertOpen();
return Buffer.concat(chunks);
};
}
return {
text,
isText,
kind,
name,
files: {
// A lone file is only ever handed to plugins as text, never as a tree
kind: kind === "file" ? "directory" : kind,
name,
readDir,
readFile,
async readTextFile(value) {
return new TextDecoder("utf-8", { fatal: true }).decode(await readFile(value));
},
},
close() {
closed = true;
zip?.close();
},
};
}
/**
* Compressing a folder puts everything under that folder inside the archive. Present the
* folder's contents as the root so plugins see the same tree they would for the directory.
*/
function unwrapSingleRoot<T extends { type: "file" | "directory" }>(entries: Map<string, T>) {
const tops = new Set<string>();
for (const entryPath of entries.keys()) {
if (entryPath === "") continue;
const top = entryPath.split("/")[0]!;
if (top === "__MACOSX" || top.startsWith(".")) continue;
tops.add(top);
}
if (tops.size !== 1) return;
const [root] = tops;
if (entries.get(root!)?.type !== "directory") return;
const prefix = `${root}/`;
const snapshot = Array.from(entries);
for (const [entryPath, value] of snapshot) {
if (entryPath === "") continue;
entries.delete(entryPath);
if (entryPath === root) continue;
if (entryPath.startsWith(prefix)) entries.set(entryPath.slice(prefix.length), value);
}
}
/** Keep legacy text plugins out of directories, archives, and arbitrary binary inputs. */
export async function runImporter(importer: ImporterPlugin, ctx: Context, input: ImportRequest) {
if (!importer.onImportSource && input.source?.type === "directory") return null;
// Avoid opening archives once for every legacy importer.
if (!importer.onImportSource && input.source?.type === "file") {
const bytes = Buffer.from(input.source.base64, "base64");
if (isZip(bytes)) return null;
}
const session = await createImportFiles(input);
try {
if (importer.onImportSource) {
const source: ImportSource | null =
session.kind === "file"
? session.isText
? { type: "text", name: session.name, text: session.text }
: null
: { type: "directory", files: session.files };
if (source == null) return null;
return await importer.onImportSource(ctx, { source });
}
if (session.isText && importer.onImport) {
return await importer.onImport(ctx, { text: session.text });
}
return null;
} finally {
session.close();
}
}
+2
View File
@@ -17,6 +17,8 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onC
size?: ButtonSize;
justify?: "start" | "center";
type?: "button" | "submit";
/** Submit a form the button is not inside, by that form's id */
form?: string;
forDropdown?: boolean;
disabled?: boolean;
title?: string;
+27
View File
@@ -0,0 +1,27 @@
# Bruno importer
Import Bruno's **Single File (YAML)** or **Bruno Collection (ZIP)** exports, or select a collection directory. Both OpenCollection `1.0.0` YAML collections and legacy `.bru` collections are supported. Individual `.bru` request files also work. A directory/ZIP must contain one collection root (`opencollection.yml`, `opencollection.yaml`, or `bruno.json`); a wrapper directory in the ZIP is allowed. Use **Choose folder** for an on-disk collection, rather than selecting its configuration file alone.
The importer consumes the shared [file-tree API](../../packages/plugin-runtime-types/IMPORTERS.md). It skips `.git`, `node_modules`, `__MACOSX`, and hidden entries. It does not install dependencies, load dotenv files, or run collection scripts.
Supported:
- Nested folders, ordering, descriptions, collection/folder headers, and variables.
- Environments, disabled variables, typed values, and secret placeholders. Environment inheritance is flattened into each selectable environment because Yaak does not have nested selectable environments.
- HTTP URLs, query/path parameters, headers, raw bodies, URL-encoded forms, multipart files, and selected binary files.
- GraphQL query and variables in Yaak's sendable body shape.
- gRPC URL, service/method, metadata, and selected message; WebSocket URL, headers, and selected message.
- Explicit auth inheritance, no auth, basic, bearer, digest, NTLM, API key, AWS credentials, common OAuth 1.0 header signing, and OAuth 2.0 flows with header tokens.
- Plain `{{variable}}` references converted to Yaak templates. Expressions, dynamic variables, and `process.env` references stay unchanged.
Scripts, assertions, actions, request-scoped variables, examples, advanced settings, and unsupported authentication configurations are retained in resource descriptions for manual migration, not executed. Alternative bodies/messages use the selected variant (or the first when unspecified); retained source variants remain in descriptions. File paths are preserved without reading files; relative paths and gRPC proto files may need to be configured after import. External secret providers, certificates, proxies, OAuth token caches, and WebSocket binary messages are not activated by the importer.
OpenCollection does not export persistent resource IDs, so Yaak derives the import source keys using its existing fallback matching.
Implementation references:
- [OpenCollection types and schema](https://github.com/opencollection-dev/opencollection)
- [Bruno's bundled export converter](https://github.com/usebruno/bruno/blob/main/packages/bruno-converters/src/opencollection/bruno-to-opencollection.ts)
- [Bruno's Share exporter](https://github.com/usebruno/bruno/blob/main/packages/bruno-app/src/utils/exporters/opencollection.js)
Legacy `.bru` parsing uses Bruno's official `@usebruno/filestore` (backed by `@usebruno/lang`). Its YAML serializers normalize parsed collections to OpenCollection before they enter the same Yaak conversion path. YAML collections are assembled directly to preserve their fields.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@yaak/importer-bruno",
"displayName": "Bruno Importer",
"version": "0.1.0",
"private": true,
"description": "Import Bruno collections",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"@usebruno/filestore": "^0.12.0",
"yaml": "^2.8.3"
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { HttpRequest } from "@yaakapp/api";
import { object, template, text, type Obj } from "./common";
type Authentication = Pick<HttpRequest, "authentication" | "authenticationType">;
export function auth(value: unknown, inheritWhenAbsent = false): Authentication {
// Bruno omits auth for 'none'. A folder without request defaults inherits from its parent.
if (value === "inherit" || (inheritWhenAbsent && value == null))
return { authenticationType: null, authentication: {} };
const a = object(value);
const type = text(a.type);
const fields = (names: string[]) =>
Object.fromEntries(names.map((name) => [name, template(a[name])]));
switch (type) {
case "basic":
case "digest":
return { authenticationType: type, authentication: fields(["username", "password"]) };
case "bearer":
return { authenticationType: type, authentication: fields(["token"]) };
case "ntlm":
return {
authenticationType: "windows",
authentication: fields(["username", "password", "domain"]),
};
case "awsv4":
if (a.profileName) break;
return {
authenticationType: type,
authentication: fields([
"accessKeyId",
"secretAccessKey",
"sessionToken",
"region",
"service",
]),
};
case "apikey":
return {
authenticationType: type,
authentication: {
...fields(["key", "value"]),
location: a.placement === "query" ? "query" : "header",
},
};
case "oauth1": {
const privateKey = object(a.privateKey);
if (
(a.placement != null && a.placement !== "header") ||
a.includeBodyHash === true ||
privateKey.type === "file"
)
break;
return {
authenticationType: type,
authentication: {
...fields([
"consumerKey",
"consumerSecret",
"verifier",
"timestamp",
"nonce",
"version",
"realm",
]),
signatureMethod: text(a.signatureMethod) || "HMAC-SHA1",
tokenKey: template(a.accessToken),
tokenSecret: template(a.accessTokenSecret),
callback: template(a.callbackUrl),
privateKey: template(typeof a.privateKey === "string" ? a.privateKey : privateKey.value),
},
};
}
case "oauth2": {
const flow = text(a.flow);
const token = object(a.tokenConfig);
const placement = object(token.placement);
// Yaak currently places OAuth tokens in a header and uses the access token.
if (
placement.query != null ||
token.source === "id_token" ||
![
"authorization_code",
"client_credentials",
"resource_owner_password_credentials",
"implicit",
].includes(flow)
)
break;
const credentials = object(a.credentials);
const owner = object(a.resourceOwner);
const pkce = object(a.pkce);
return {
authenticationType: type,
authentication: {
...fields(["authorizationUrl", "accessTokenUrl", "scope", "state"]),
grantType: flow === "resource_owner_password_credentials" ? "password" : flow,
clientId: template(credentials.clientId),
clientSecret: template(credentials.clientSecret),
credentials: credentials.placement === "basic_auth_header" ? "basic" : "body",
redirectUri: template(a.callbackUrl),
username: template(owner.username),
password: template(owner.password),
usePkce: a.pkce != null && pkce.disabled !== true,
pkceChallengeMethod: pkce.method === "plain" ? "plain" : "S256",
headerPrefix: placement.header == null ? "Bearer" : template(placement.header),
},
};
}
}
return { authenticationType: "none", authentication: {} };
}
export function manualAuth(value: unknown): Obj | undefined {
const a = object(value);
if (!Object.keys(a).length) return;
if (auth(value).authenticationType === "none") return a;
// OAuth settings beyond the core flow are retained for manual migration.
if (a.type === "oauth2" && (a.additionalParameters || a.refreshTokenUrl || a.settings)) return a;
}
+109
View File
@@ -0,0 +1,109 @@
import type { HttpRequest, HttpRequestHeader } from "@yaakapp/api";
import { headers, object, rows, selected, template, text } from "./common";
export function body(
value: unknown,
graphql: boolean,
rawHeaders: unknown,
inheritedHeaders: HttpRequestHeader[] = [],
): Pick<HttpRequest, "body" | "bodyType" | "headers"> {
const b = object(selected(value, "body"));
const requestHeaders = headers(rawHeaders);
const result = (bodyType: string | null, data: Record<string, unknown>, contentType = "") => {
if (
contentType &&
![...inheritedHeaders, ...requestHeaders].some((h) => h.name.toLowerCase() === "content-type")
) {
requestHeaders.push({ name: "Content-Type", value: contentType, enabled: true });
}
return { bodyType, body: data, headers: requestHeaders };
};
if (graphql) {
return result(
"graphql",
{
query: template(b.query),
variables: template(
typeof b.variables === "object" && b.variables != null
? JSON.stringify(b.variables, null, 2)
: b.variables,
),
...(typeof b.operationName === "string"
? { operationName: template(b.operationName) }
: {}),
},
"application/json",
);
}
const types: Record<string, [string, string]> = {
json: ["application/json", "application/json"],
xml: ["application/xml", "application/xml"],
text: ["other", "text/plain"],
sparql: ["other", "application/sparql-query"],
};
const type = text(b.type);
const rawType = Object.hasOwn(types, type) ? types[type] : undefined;
if (rawType) return result(rawType[0], { text: template(b.data) }, rawType[1]);
if (type === "form-urlencoded")
return result(
"application/x-www-form-urlencoded",
{ form: headers(b.data) },
"application/x-www-form-urlencoded",
);
if (type === "multipart-form") {
const form = rows(b.data).flatMap<Record<string, unknown>>((v) => {
const base = {
name: template(v.name),
enabled: v.disabled !== true,
...(v.contentType ? { contentType: template(v.contentType) } : {}),
};
return v.type === "file"
? (Array.isArray(v.value) ? v.value : [v.value]).map((file: unknown) => ({
...base,
file: template(file),
}))
: [{ ...base, value: template(v.value) }];
});
return result("multipart/form-data", { form }, "multipart/form-data");
}
if (type === "file") {
const file = rows(b.data).find((v) => v.selected === true);
return file
? result("binary", { filePath: template(file.filePath) }, text(file.contentType))
: result(null, {});
}
return result(null, {});
}
export function urlAndParams(url: unknown, value: unknown) {
const params = rows(value);
const urlParameters = params.map((p) => ({
name: `${p.type === "path" ? ":" : ""}${template(p.name)}`,
value: template(p.value),
enabled: p.disabled !== true,
}));
// Bruno stores query parameters in both the URL and the editor rows. Yaak appends rows.
const names = new Set(params.filter((p) => p.type !== "path").map((p) => text(p.name)));
const raw = text(url);
const hash = raw.indexOf("#");
const fragment = hash < 0 ? "" : raw.slice(hash);
const base = hash < 0 ? raw : raw.slice(0, hash);
const q = base.indexOf("?");
if (q < 0 || names.size === 0) return { url: template(raw), urlParameters };
const query = base
.slice(q + 1)
.split("&")
.filter((pair) => {
const key = pair.split("=")[0] ?? "";
try {
return !names.has(decodeURIComponent(key.replaceAll("+", " ")));
} catch {
return !names.has(key);
}
})
.join("&");
return {
url: template(`${base.slice(0, q)}${query ? `?${query}` : ""}${fragment}`),
urlParameters,
};
}
+69
View File
@@ -0,0 +1,69 @@
import type { EnvironmentVariable, HttpRequestHeader } from "@yaakapp/api";
import YAML from "yaml";
export type Obj = Record<string, unknown>;
export function object(value: unknown): Obj {
return value != null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : {};
}
export function rows(value: unknown): Obj[] {
return Array.isArray(value) ? value.map(object) : [];
}
export function text(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
export function template(value: unknown): string {
return text(value).replace(/{{\s*([^{}]+?)\s*}}/g, (full, name: string) => {
// Bruno's expressions, process.env and dynamic variables have different semantics.
if (
name.startsWith("process.env.") ||
["true", "false", "null"].includes(name) ||
!/^[\p{L}_][\p{L}\p{N}_.-]*$/u.test(name)
)
return full;
return `\${[ ${name} ]}`;
});
}
export function selected(value: unknown, key: string): unknown {
if (!Array.isArray(value)) return value;
const variants = rows(value);
return (variants.find((v) => v.selected === true) ?? variants[0])?.[key];
}
export function variables(value: unknown): EnvironmentVariable[] {
return rows(value).map((v) => {
const raw = selected(v.value, "value");
return {
name: text(v.name),
value: v.secret === true ? "" : template(typeof raw === "object" ? object(raw).data : raw),
enabled: v.disabled !== true,
};
});
}
export function headers(value: unknown): HttpRequestHeader[] {
return rows(value).map((h) => ({
name: template(h.name),
value: template(h.value),
enabled: h.disabled !== true,
}));
}
export function description(item: Obj, manual: Obj = {}): string {
const info = object(item.info);
const content = (v: unknown) => (typeof v === "string" ? v : text(object(v).content));
const parts = [content(info.description), content(item.docs)].filter(Boolean);
const retained = Object.fromEntries(Object.entries(manual).filter(([, v]) => v != null));
if (Object.keys(retained).length) {
// Keep unsupported executable features as inert text for manual migration.
const yaml = YAML.stringify(retained).trimEnd();
const fence = "`".repeat(Math.max(3, ...[...yaml.matchAll(/`+/g)].map((m) => m[0].length + 1)));
parts.push(
`Bruno source fields for manual review (scripts are not executed):\n\n${fence}yaml\n${yaml}\n${fence}`,
);
}
return parts.join("\n\n");
}
@@ -0,0 +1,39 @@
import type { PartialImportResources } from "@yaakapp/api";
import { rows, text, variables, type Obj } from "./common";
export function environments(
value: unknown,
workspaceId: string,
id: () => string,
): PartialImportResources["environments"] {
const source = rows(value);
const byName = new Map(source.map((e) => [text(e.name), e]));
const resolve = (env: Obj, seen = new Set<Obj>()): ReturnType<typeof variables> => {
if (seen.has(env)) throw new Error("Bruno environment inheritance contains a cycle");
if (seen.size > 100) throw new Error("Bruno environment inheritance is too deeply nested");
seen.add(env);
const own = variables(env.variables);
const parentName = text(env.extends);
if (!parentName) return own;
const parent = byName.get(parentName);
if (!parent)
throw new Error(
`Bruno environment "${text(env.name)}" extends missing environment "${parentName}"`,
);
const overrides = new Set(own.filter((v) => v.enabled).map((v) => v.name));
return [...resolve(parent, seen).filter((v) => v.enabled && !overrides.has(v.name)), ...own];
};
// Yaak has one level of selectable environments. Flatten Bruno's extends chain so
// inheritance survives the host clearing environment parent IDs during import.
return source.map((e, sortPriority) => ({
model: "environment",
id: id(),
workspaceId,
name: text(e.name) || "Environment",
parentModel: "environment",
parentId: null,
variables: resolve(e),
color: text(e.color) || null,
sortPriority,
}));
}
+187
View File
@@ -0,0 +1,187 @@
import path from "node:path";
import {
parseCollection,
parseEnvironment,
parseFolder,
parseRequest,
stringifyCollection,
stringifyEnvironment,
stringifyFolder,
stringifyRequest,
} from "@usebruno/filestore";
import type { ImportFileEntry, ImportFiles } from "@yaakapp/api";
import YAML from "yaml";
import { object, rows, text, type Obj } from "./common";
const ignored = new Set([".git", "node_modules", "__MACOSX"]);
const yaml = (value: string) => object(YAML.parse(value, { maxAliasCount: 0 }));
/** Assemble a collection from the virtual file tree. No direct filesystem access in the plugin. */
export function isBruRequestFile(fileName: string): boolean {
return /\.bru$/i.test(fileName) && !["collection.bru", "folder.bru"].includes(fileName);
}
/** A lone .bru request, wrapped as a one-request bundled collection */
export function singleBruRequest(contents: string, fileName: string): Obj {
return {
opencollection: "1.0.0",
bundled: true,
info: { name: fileName.replace(/\.bru$/i, "") },
items: [yaml(stringifyRequest(parseRequest(contents, { format: "bru" }), { format: "yml" }))],
};
}
const markerNames = ["bruno.json", "opencollection.yml", "opencollection.yaml"];
export async function readBrunoCollection(files: ImportFiles): Promise<Obj | null> {
const listings = new Map<string, ImportFileEntry[]>();
const list = async (dir: string) => {
let entries = listings.get(dir);
if (entries == null) {
entries = (await files.readDir(dir)).filter(
(e) => !ignored.has(e.name) && !e.name.startsWith("."),
);
listings.set(dir, entries);
}
return entries;
};
const has = async (dir: string, name: string) =>
(await list(dir)).some((e) => e.type === "file" && e.name === name);
const hasMarker = async (dir: string) => {
for (const name of markerNames) if (await has(dir, name)) return true;
return false;
};
// The selected directory must be the collection itself. Subfolders are only checked to give a
// better error than "not supported" when the user picked the folder above it.
const rootPath = "";
if (!(await hasMarker(rootPath))) {
const top = await list(rootPath);
const [only] = top;
if (top.length === 1 && only?.type === "file" && isBruRequestFile(only.name)) {
return singleBruRequest(await files.readTextFile(only.path), only.name);
}
for (const entry of top) {
if (entry.type === "directory" && (await hasMarker(entry.path))) {
throw new Error(
"This folder contains Bruno collections. Select the collection directory itself.",
);
}
}
return null;
}
const at = (name: string) => name;
const yamlMarker = (await has(rootPath, "opencollection.yml"))
? at("opencollection.yml")
: (await has(rootPath, "opencollection.yaml"))
? at("opencollection.yaml")
: undefined;
const isYaml = yamlMarker != null;
const root = isYaml
? yaml(await files.readTextFile(yamlMarker))
: object(JSON.parse(await files.readTextFile(at("bruno.json"))));
if (isYaml && root.opencollection !== "1.0.0")
throw new Error(`Unsupported Bruno collection format version: ${text(root.opencollection)}`);
const load = async (p: string, parse: (s: string) => Obj) => {
try {
return parse(await files.readTextFile(p));
} catch (error) {
throw new Error(
`Unable to import ${p}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const assemble = async (dir: string): Promise<Obj[]> => {
const items: Obj[] = [];
for (const entry of await list(dir)) {
if (dir === rootPath && entry.name === "environments") continue;
if (entry.type === "directory") {
let configPath: string | undefined;
for (const name of isYaml ? ["folder.yml", "folder.yaml"] : ["folder.bru"]) {
if (await has(entry.path, name)) {
configPath = `${entry.path}/${name}`;
break;
}
}
const folder = configPath
? await load(configPath, (s) =>
isYaml
? yaml(s)
: yaml(stringifyFolder(parseFolder(s, { format: "bru" }), { format: "yml" })),
)
: {};
const children = await assemble(entry.path);
items.push({
...folder,
info: { name: entry.name, ...object(folder.info), type: "folder" },
items: children,
});
} else if (
isYaml &&
/\.ya?ml$/i.test(entry.name) &&
!["opencollection.yml", "opencollection.yaml", "folder.yml", "folder.yaml"].includes(
entry.name,
)
) {
const item = await load(entry.path, yaml);
if (object(item.info).type || item.http || item.graphql || item.grpc || item.websocket)
items.push(item);
} else if (
!isYaml &&
/\.bru$/i.test(entry.name) &&
!["collection.bru", "folder.bru"].includes(entry.name)
) {
items.push(
await load(entry.path, (s) =>
yaml(stringifyRequest(parseRequest(s, { format: "bru" }), { format: "yml" })),
),
);
}
}
return items;
};
const environments: Obj[] = [];
const hasEnvDir = (await list(rootPath)).some(
(e) => e.type === "directory" && e.name === "environments",
);
const envFiles = hasEnvDir
? (await list(at("environments"))).filter(
(e) => e.type === "file" && (isYaml ? /\.ya?ml$/i.test(e.name) : /\.bru$/i.test(e.name)),
)
: [];
for (const p of envFiles.map((e) => e.path)) {
const env = await load(p, (s) =>
isYaml
? yaml(s)
: yaml(stringifyEnvironment(parseEnvironment(s, { format: "bru" }), { format: "yml" })),
);
environments.push({ ...env, name: path.posix.basename(p).replace(/\.(?:bru|ya?ml)$/i, "") });
}
const items = await assemble(rootPath);
if (isYaml)
return {
...root,
bundled: true,
items: [...rows(root.items), ...items],
config: {
...object(root.config),
environments: [...rows(object(root.config).environments), ...environments],
},
};
const collectionRoot = (await has(rootPath, "collection.bru"))
? parseCollection(await files.readTextFile(at("collection.bru")), { format: "bru" })
: undefined;
const normalized = yaml(
stringifyCollection(
collectionRoot ?? {},
{ ...root, name: text(root.name) || files.name },
{ format: "yml" },
),
);
return {
...normalized,
bundled: true,
items,
config: { ...object(normalized.config), environments },
};
}
+227
View File
@@ -0,0 +1,227 @@
import type { HttpRequestHeader, PartialImportResources, PluginDefinition } from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import YAML from "yaml";
import { auth, manualAuth } from "./auth";
import { body, urlAndParams } from "./body";
import { environments } from "./environments";
import { isBruRequestFile, readBrunoCollection, singleBruRequest } from "./files";
import {
description,
headers,
object,
rows,
selected,
template,
text,
variables,
type Obj,
} from "./common";
export const plugin: PluginDefinition = {
importer: {
name: "Bruno",
description: "Import Bruno collections",
async onImportSource(_ctx, { source }) {
if (source.type === "text") {
return isBruRequestFile(source.name)
? convertBruno(YAML.stringify(singleBruRequest(source.text, source.name)))
: convertBruno(source.text);
}
const root = await readBrunoCollection(source.files);
return root ? convertBruno(YAML.stringify(root)) : null;
},
},
};
export function convertBruno(contents: string): ImportPluginResponse {
let root: Obj;
try {
// Bundled exports don't need aliases. Disallow cycles before walking the item tree.
root = object(YAML.parse(contents, { maxAliasCount: 0 }));
} catch {
return null;
}
if (typeof root.opencollection !== "string") return null;
if (root.opencollection !== "1.0.0")
throw new Error(`Unsupported Bruno collection format version: ${root.opencollection}`);
if (root.bundled === false || (!Array.isArray(root.items) && root.bundled !== true)) {
throw new Error(
"Select the collection using Choose folder, or export a single YAML file from Bruno's Share menu. A collection configuration file alone does not contain the requests.",
);
}
const resources: PartialImportResources = {
workspaces: [],
environments: [],
folders: [],
httpRequests: [],
grpcRequests: [],
websocketRequests: [],
};
let nextId = 0;
const id = () => `GENERATE_ID::${++nextId}`;
const workspaceId = id();
const defaults = object(root.request);
const config = object(root.config);
const environmentSettings = rows(config.environments)
.map((e) => ({
name: e.name,
externalSecrets: e.externalSecrets,
dotEnvFilePath: e.dotEnvFilePath,
clientCertificates: e.clientCertificates,
}))
.filter((e) => e.externalSecrets || e.dotEnvFilePath || e.clientCertificates);
resources.workspaces.push({
model: "workspace",
id: workspaceId,
name: text(object(root.info).name) || "Bruno Import",
headers: headers(defaults.headers),
...auth(defaults.auth),
description: description(root, {
scripts: defaults.scripts,
actions: defaults.actions,
settings: defaults.settings,
auth: manualAuth(defaults.auth),
...(environmentSettings.length ? { environmentSettings } : {}),
...Object.fromEntries(Object.entries(config).filter(([key]) => key !== "environments")),
}),
});
resources.environments.push({
model: "environment",
id: id(),
workspaceId,
name: "Base Environment",
parentModel: "workspace",
parentId: null,
variables: variables(defaults.variables),
});
resources.environments.push(...environments(config.environments, workspaceId, id));
const visit = (
items: unknown,
folderId: string | null,
depth = 0,
inheritedHeaders: HttpRequestHeader[] = headers(defaults.headers),
) => {
if (depth > 100) throw new Error("Bruno folders are nested too deeply");
for (const [index, item] of rows(items).entries()) {
const info = object(item.info);
const type = text(info.type);
const common = {
id: id(),
workspaceId,
folderId,
name: text(info.name) || "Untitled",
sortPriority: typeof info.seq === "number" && Number.isFinite(info.seq) ? info.seq : index,
};
if (type === "folder" || (type === "" && Array.isArray(item.items))) {
const request = object(item.request);
resources.folders.push({
...common,
model: "folder",
headers: headers(request.headers),
...auth(request.auth, item.request == null),
description: description(item, {
scripts: request.scripts,
actions: request.actions,
settings: request.settings,
auth: manualAuth(request.auth),
}),
});
if (Array.isArray(request.variables) && request.variables.length) {
resources.environments.push({
model: "environment",
id: id(),
workspaceId,
name: "Folder Environment",
parentModel: "folder",
parentId: common.id,
variables: variables(request.variables),
});
}
visit(item.items, common.id, depth + 1, [...inheritedHeaders, ...headers(request.headers)]);
continue;
}
const r = object(item[type]);
const settings = object(item.settings);
const desc = description(item, {
runtime: item.runtime,
examples: item.examples,
settings: item.settings,
auth: manualAuth(r.auth),
...(Array.isArray(r.body) || object(r.body).type === "file"
? { bodyVariants: r.body }
: type === "http" &&
r.body != null &&
!["json", "xml", "text", "sparql", "form-urlencoded", "multipart-form"].includes(
text(object(r.body).type),
)
? { unsupportedBody: r.body }
: {}),
});
if (type === "http" || type === "graphql") {
resources.httpRequests.push({
...common,
model: "http_request",
description: desc,
...urlAndParams(r.url, r.params),
method: text(r.method) || (type === "graphql" ? "POST" : "GET"),
...auth(r.auth),
...body(r.body, type === "graphql", r.headers, inheritedHeaders),
...(typeof settings.followRedirects === "boolean"
? { settingFollowRedirects: { enabled: true, value: settings.followRedirects } }
: {}),
...(typeof settings.timeout === "number" &&
Number.isInteger(settings.timeout) &&
settings.timeout >= 0 &&
settings.timeout <= 2147483647
? { settingRequestTimeout: { enabled: true, value: settings.timeout } }
: {}),
});
} else if (type === "grpc") {
const [service, method] = text(r.method).replace(/^\//, "").split("/");
resources.grpcRequests.push({
...common,
model: "grpc_request",
url: template(r.url),
service: service || null,
method: method || null,
message: template(selected(r.message, "message")),
metadata: headers(r.metadata),
...auth(r.auth),
description: description(item, {
runtime: item.runtime,
protoFilePath: r.protoFilePath,
messageVariants: Array.isArray(r.message) ? r.message : undefined,
auth: manualAuth(r.auth),
}),
});
} else if (type === "websocket") {
const message = object(selected(r.message, "message"));
resources.websocketRequests.push({
...common,
model: "websocket_request",
url: template(r.url),
headers: headers(r.headers),
...auth(r.auth),
message: message.type === "binary" ? "" : template(message.data),
description: description(item, {
runtime: item.runtime,
settings: item.settings,
message: r.message,
auth: manualAuth(r.auth),
}),
});
} else {
// Unknown items remain visible in the import instead of silently disappearing.
resources.folders.push({
...common,
model: "folder",
description: description(item, { unsupportedItem: item }),
});
}
}
};
visit(root.items, null);
// OpenCollection has no persistent resource IDs. Let the host derive source keys.
return { resources };
}
+118
View File
@@ -0,0 +1,118 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { Context, ImportFiles } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { createImportFiles, runImporter } from "../../../packages/plugin-runtime/src/importFiles";
import { plugin } from "../src";
import { readBrunoCollection } from "../src/files";
const ctx = {} as Context;
describe.each(["opencollection", "legacy"])("Bruno %s file tree", (name) => {
test("imports the same resources from a ZIP or directory, including nested folders and environments", async () => {
const directory = fileURLToPath(new URL(`./fixtures/${name}/Demo`, import.meta.url));
const bytes = await readFile(new URL(`./fixtures/${name}.zip`, import.meta.url));
const zipped = await runImporter(plugin.importer!, ctx, {
content: "",
source: { type: "file", name: `${name}.zip`, base64: bytes.toString("base64") },
});
const unzipped = await runImporter(plugin.importer!, ctx, {
content: "",
source: { type: "directory", path: directory },
});
expect(zipped).toEqual(unzipped);
expect(zipped?.resources.workspaces[0]?.name).toBe(
name === "legacy" ? "Legacy Bruno" : "Directory YAML",
);
expect(zipped?.resources.httpRequests).toHaveLength(2);
expect(zipped?.resources.httpRequests.find((r) => r.name === "Get Users")).toMatchObject({
url: "${[ host ]}/users",
authenticationType: null,
});
expect(zipped?.resources.httpRequests.find((r) => r.name === "Query")).toMatchObject({
bodyType: "graphql",
body: { query: "query { users { id } }" },
});
expect(zipped?.resources.environments[0]?.variables).toContainEqual({
name: "host",
value: "https://example.com",
enabled: true,
});
expect(zipped?.resources.environments[1]?.variables).toContainEqual({
name: "token",
value: "sample",
enabled: true,
});
expect(zipped?.resources.folders.find((f) => f.name === "Users")).toMatchObject({
sortPriority: 3,
});
});
});
test("still imports a bundled YAML file through the new runtime entry point", async () => {
const bytes = await readFile(new URL("./fixtures/collection.yaml", import.meta.url));
const result = await runImporter(plugin.importer!, ctx, {
content: bytes.toString(),
source: { type: "file", name: "collection.yaml", base64: bytes.toString("base64") },
});
expect(result?.resources.workspaces[0]?.name).toBe("Example API");
});
test("imports pasted YAML through the file hook's virtual input file", async () => {
const content = await readFile(new URL("./fixtures/collection.yaml", import.meta.url), "utf8");
const result = await runImporter(plugin.importer!, ctx, { content });
expect(result?.resources.workspaces[0]?.name).toBe("Example API");
});
test("supports an individual .bru request", async () => {
const bytes = await readFile(new URL("./fixtures/legacy/Demo/Users/get.bru", import.meta.url));
const result = await runImporter(plugin.importer!, ctx, {
content: bytes.toString(),
source: { type: "file", name: "get.bru", base64: bytes.toString("base64") },
});
expect(result?.resources.httpRequests[0]?.name).toBe("Get Users");
});
test("does not claim an unrelated binary file", async () => {
expect(
await runImporter(plugin.importer!, ctx, {
content: "",
source: { type: "file", name: "body.bin", base64: "/w==" },
}),
).toBeNull();
});
test("rejects a folder of collections before reading their contents", async () => {
const files: ImportFiles = {
name: "collections",
kind: "directory",
async readDir(dir = "") {
return dir === ""
? ["a", "b"].map((name) => ({ name, path: name, type: "directory" as const }))
: [{ name: "bruno.json", path: `${dir}/bruno.json`, type: "file" }];
},
async readFile() {
throw new Error("should not read");
},
async readTextFile() {
throw new Error("should not read");
},
};
await expect(readBrunoCollection(files)).rejects.toThrow("Select the collection directory");
});
test("reports parse errors with the relative file path", async () => {
const session = await createImportFiles({
content: "meta { invalid",
source: {
type: "file",
name: "broken.bru",
base64: Buffer.from("meta { invalid").toString("base64"),
},
});
try {
await expect(readBrunoCollection(session.files)).rejects.toThrow();
} finally {
session.close();
}
});
+136
View File
@@ -0,0 +1,136 @@
# Synthetic bundled export following Bruno's OpenCollection 1.0.0 exporter.
opencollection: 1.0.0
info:
name: Example API
request:
auth:
type: bearer
token: "{{token}}"
headers:
- name: X-Collection
value: "{{client}}"
variables:
- name: host
value: https://example.com
- name: client
value: yaak
config:
environments:
- name: Development
extends: Shared
variables:
- name: host
value: https://dev.example.com
- name: token
secret: true
- name: disabled
value: ignored
disabled: true
- name: Shared
color: "#123456"
variables:
- name: count
value:
type: number
data: "42"
items:
- info:
name: Users
type: folder
seq: 2
request:
auth: inherit
variables:
- name: userId
value: "123"
items:
- info:
name: Get User
type: http
seq: 3
description: Fetch one user
http:
method: GET
url: "{{host}}/users/:id?expand=profile&keep=1#details"
auth: inherit
params:
- name: id
value: "{{userId}}"
type: path
- name: expand
value: profile
type: query
- name: inactive
value: "true"
type: query
disabled: true
headers:
- name: X-Disabled
value: ignored
disabled: true
settings:
timeout: 2000
followRedirects: false
- info:
name: GraphQL
type: graphql
seq: 1
graphql:
method: POST
url: "{{host}}/graphql"
auth:
type: basic
username: "{{client}}"
password: example
body:
query: "query Get($id: ID!) { node(id: $id) { id } }"
variables: '{"id":"{{userId}}"}'
- info:
name: Public
type: http
seq: 1
http:
method: GET
url: "{{host}}/status"
- info:
name: Upload
type: http
http:
method: POST
url: "{{host}}/upload"
body:
type: multipart-form
data:
- name: label
type: text
value: "{{client}}"
- name: files
type: file
value: [./first.txt, ./second.txt]
contentType: text/plain
- info:
name: Hello RPC
type: grpc
grpc:
url: localhost:50051
method: /example.Greeter/SayHello
message: '{"name":"{{client}}"}'
metadata:
- name: authorization
value: "Bearer {{token}}"
- info:
name: Events
type: websocket
websocket:
url: wss://example.com/events
auth: inherit
message:
type: json
data: '{"subscribe":"{{client}}"}'
docs:
type: text/markdown
content: Synthetic migration fixture.
bundled: true
extensions:
bruno:
exportedUsing: Bruno/3.0.0
Binary file not shown.
@@ -0,0 +1,4 @@
meta {
name: Users
seq: 3
}
@@ -0,0 +1,10 @@
meta {
name: Get Users
type: http
seq: 1
}
get {
url: {{host}}/users
body: none
auth: inherit
}
@@ -0,0 +1,13 @@
meta {
name: Query
type: graphql
seq: 2
}
post {
url: {{host}}/graphql
body: graphql
auth: none
}
body:graphql {
query { users { id } }
}
@@ -0,0 +1 @@
{ "version": "1", "name": "Legacy Bruno", "type": "collection" }
@@ -0,0 +1,9 @@
vars:pre-request {
host: https://example.com
}
auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}
@@ -0,0 +1,3 @@
vars {
token: sample
}
Binary file not shown.
@@ -0,0 +1,6 @@
info:
name: Users
type: folder
seq: 3
request:
auth: inherit
@@ -0,0 +1,8 @@
info:
name: Get Users
type: http
seq: 1
http:
method: GET
url: "{{host}}/users"
auth: inherit
@@ -0,0 +1,7 @@
info:
name: Query
type: graphql
graphql:
url: "{{host}}/graphql"
body:
query: "query { users { id } }"
@@ -0,0 +1,4 @@
name: Local
variables:
- name: token
value: sample
@@ -0,0 +1,7 @@
opencollection: 1.0.0
info:
name: Directory YAML
request:
variables:
- name: host
value: https://example.com
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2022 Anoop M D, Anusree P S and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,16 @@
# Upstream Bruno fixtures
These two complete fixture directories are copied byte-for-byte from
[usebruno/bruno](https://github.com/usebruno/bruno) at commit
[`4eb7e585f0fb318b2ef8d88b34635da633f3acf4`](https://github.com/usebruno/bruno/commit/4eb7e585f0fb318b2ef8d88b34635da633f3acf4).
- `bru-migration/`: [`tests/collection/migrate-to-yml/fixtures/collection`](https://github.com/usebruno/bruno/tree/4eb7e585f0fb318b2ef8d88b34635da633f3acf4/tests/collection/migrate-to-yml/fixtures/collection). Exercises collection/folder headers, environments, request variables, JSON bodies, and retained scripts/assertions.
- `yaml-docs/`: [`packages/bruno-cli/tests/commands/docs/fixtures/yml-collection`](https://github.com/usebruno/bruno/tree/4eb7e585f0fb318b2ef8d88b34635da633f3acf4/packages/bruno-cli/tests/commands/docs/fixtures/yml-collection). Exercises YAML collection metadata, ordering, and repeated request names in different folders.
The upstream MIT license is preserved in [LICENSE.md](./LICENSE.md). This directory
is excluded from formatting so upstream inputs remain unchanged. To refresh,
copy both complete directories from a new pinned revision and update these links;
do not edit the inputs to accommodate importer behavior.
The tests only import the fixtures. They do not send requests or execute the
embedded Bruno scripts. Existing synthetic fixtures cover additional edge cases.
@@ -0,0 +1,7 @@
meta {
name: api
}
headers {
X-Folder-Header: api-folder
}
@@ -0,0 +1,24 @@
meta {
name: get-users
type: http
seq: 1
}
get {
url: {{host}}/api/echo/json
body: none
auth: none
}
headers {
Accept: application/json
}
script:pre-request {
// pre-request script in folder request
bru.setVar("requestVar", "test");
}
assert {
res.status: eq 200
}
@@ -0,0 +1,10 @@
{
"version": "1",
"name": "migration-test",
"type": "collection",
"collectionVersion": "v9.9.9",
"ignore": [
"node_modules",
".git"
]
}
@@ -0,0 +1,13 @@
headers {
X-Collection-Header: migration-test
}
script:pre-request {
// collection pre-request script
bru.setVar("collectionVar", "hello");
}
docs {
# Migration Test Collection
This collection is used to test bru to yml migration.
}
@@ -0,0 +1,3 @@
vars {
host: http://localhost:8081
}
@@ -0,0 +1,3 @@
vars {
host: https://api.example.com
}
@@ -0,0 +1,15 @@
meta {
name: ping
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
assert {
res.status: eq 200
}
@@ -0,0 +1,25 @@
meta {
name: post-json
type: http
seq: 2
}
post {
url: {{host}}/api/echo/json
body: json
auth: none
}
headers {
Content-Type: application/json
}
body:json {
{
"message": "hello from migration test"
}
}
assert {
res.status: eq 200
}
@@ -0,0 +1,4 @@
info:
name: folder_1
type: folder
seq: 1
@@ -0,0 +1,8 @@
info:
name: request_1
type: http
seq: 1
http:
method: GET
url: https://api.example.com/folder-1/one
@@ -0,0 +1,4 @@
info:
name: folder_2
type: folder
seq: 2
@@ -0,0 +1,8 @@
info:
name: request_1
type: http
seq: 1
http:
method: GET
url: https://api.example.com/folder-2/one
@@ -0,0 +1,4 @@
opencollection: "1.0.0"
info:
name: ymlcollection
version: "3.4.0"
@@ -0,0 +1,8 @@
info:
name: request_1
type: http
seq: 1
http:
method: GET
url: https://api.example.com/one
@@ -0,0 +1,8 @@
info:
name: request_2
type: http
seq: 2
http:
method: GET
url: https://api.example.com/two
+459
View File
@@ -0,0 +1,459 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vite-plus/test";
import YAML from "yaml";
import { convertBruno } from "../src";
import { auth } from "../src/auth";
import { body, urlAndParams } from "../src/body";
import { template, variables } from "../src/common";
function collection(items: unknown[] = [], extra: Record<string, unknown> = {}) {
return { opencollection: "1.0.0", info: { name: "Test" }, bundled: true, items, ...extra };
}
const fixture = readFileSync(new URL("./fixtures/collection.yaml", import.meta.url), "utf8");
describe("Bruno OpenCollection importer", () => {
test("imports a bundled export with folders, scopes, disabled rows, and all four request types", () => {
const imported = convertBruno(fixture)!;
const {
workspaces: [workspace],
folders: [folder],
environments,
httpRequests,
grpcRequests: [grpc],
websocketRequests: [ws],
} = imported.resources;
expect(workspace).toMatchObject({
name: "Example API",
authenticationType: "bearer",
authentication: { token: "${[ token ]}" },
description: "Synthetic migration fixture.",
});
expect(folder).toMatchObject({ name: "Users", sortPriority: 2, authenticationType: null });
const [base, dev, shared, folderEnv] = environments;
expect(base).toMatchObject({
parentModel: "workspace",
parentId: null,
variables: [
{ name: "host", value: "https://example.com" },
{ name: "client", value: "yaak" },
],
});
expect(dev).toMatchObject({
parentModel: "environment",
parentId: null,
variables: [
{ name: "count", value: "42" },
{ name: "host" },
{ name: "token", value: "" },
{ name: "disabled", enabled: false },
],
});
expect(shared).toMatchObject({ color: "#123456", variables: [{ name: "count", value: "42" }] });
expect(folderEnv).toMatchObject({ parentModel: "folder", parentId: folder!.id });
const [get, graphql, publicRequest, upload] = httpRequests;
expect(get).toMatchObject({
folderId: folder!.id,
authenticationType: null,
sortPriority: 3,
url: "${[ host ]}/users/:id?keep=1#details",
urlParameters: [
{ name: ":id", value: "${[ userId ]}", enabled: true },
{ name: "expand", value: "profile", enabled: true },
{ name: "inactive", value: "true", enabled: false },
],
headers: [{ name: "X-Disabled", value: "ignored", enabled: false }],
settingFollowRedirects: { enabled: true, value: false },
settingRequestTimeout: { enabled: true, value: 2000 },
});
expect(graphql).toMatchObject({
bodyType: "graphql",
method: "POST",
authenticationType: "basic",
authentication: { username: "${[ client ]}" },
body: {
query: "query Get($id: ID!) { node(id: $id) { id } }",
variables: '{"id":"${[ userId ]}"}',
},
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
});
expect(publicRequest).toMatchObject({ authenticationType: "none", folderId: null });
expect(upload?.body?.form).toEqual([
{ name: "label", value: "${[ client ]}", enabled: true },
{ name: "files", file: "./first.txt", contentType: "text/plain", enabled: true },
{ name: "files", file: "./second.txt", contentType: "text/plain", enabled: true },
]);
expect(grpc).toMatchObject({
service: "example.Greeter",
method: "SayHello",
message: '{"name":"${[ client ]}"}',
});
expect(ws).toMatchObject({
message: '{"subscribe":"${[ client ]}"}',
authenticationType: null,
});
const all = Object.values(imported.resources).flat();
expect(new Set(all.map((r) => r.id)).size).toBe(all.length);
expect(
all.filter((r) => r.model !== "workspace").every((r) => r.workspaceId === workspace!.id),
).toBe(true);
expect(imported.sourceKeys).toBeUndefined();
expect(convertBruno(fixture)).toEqual(imported);
});
test("accepts JSON serialization and inline collections without a bundled flag", () => {
expect(convertBruno(JSON.stringify(YAML.parse(fixture)))).toEqual(convertBruno(fixture));
expect(
convertBruno(JSON.stringify({ opencollection: "1.0.0", items: [] }))?.resources.workspaces,
).toHaveLength(1);
expect(convertBruno(JSON.stringify(collection()))?.resources.httpRequests).toHaveLength(0);
});
test.each([
"",
"not: [valid",
"null",
"[]",
"openapi: 3.1.0",
'{"info":{"name":"Postman"},"item":[]}',
"meta { name: Test }",
"opencollection: 1.0.0\nbundled: true\nitems: &items [*items]",
])("ignores unrelated, malformed, or cyclic input: %s", (input) => {
expect(convertBruno(input)).toBeNull();
});
test("rejects directory config and unknown format versions with actionable errors", () => {
expect(() => convertBruno("opencollection: 1.0.0\ninfo:\n name: Directory")).toThrow(
"Share menu",
);
expect(() => convertBruno(JSON.stringify(collection([], { bundled: false })))).toThrow(
"single YAML file",
);
expect(() => convertBruno(JSON.stringify(collection([], { opencollection: "2.0.0" })))).toThrow(
"Unsupported Bruno collection format version",
);
});
test("rejects missing and cyclic environment parents", () => {
for (const environments of [
[{ name: "A", extends: "missing" }],
[{ name: "A", extends: "A" }],
[
{ name: "A", extends: "B" },
{ name: "B", extends: "A" },
],
]) {
expect(() =>
convertBruno(JSON.stringify(collection([], { config: { environments } }))),
).toThrow(/environment/);
}
});
test("retains scripts, request variables, unsupported authentication, and unknown items for manual migration", () => {
const result = convertBruno(
JSON.stringify(
collection([
{
info: { name: "Scripted", type: "http" },
http: { url: "{{host}}", auth: { type: "wsse", username: "test" } },
runtime: {
scripts: [{ code: "bru.setVar('token', 'example');\n```" }],
variables: [{ name: "host", value: "https://example.com" }],
},
},
{ info: { name: "App", type: "app" }, code: "example" },
]),
),
)!;
expect(result.resources.httpRequests[0]).toMatchObject({ authenticationType: "none" });
const desc = result.resources.httpRequests[0]!.description;
expect(desc).toContain("not executed");
expect(desc).toContain("bru.setVar");
expect(desc).toContain("variables:");
expect(desc).toContain("wsse");
expect(desc).toContain("````yaml");
expect(result.resources.folders[0]?.description).toContain("unsupportedItem:");
});
test("does not override a collection Content-Type with a generated body header", () => {
const result = convertBruno(
JSON.stringify(
collection(
[{ info: { name: "JSON", type: "http" }, http: { body: { type: "json", data: "{}" } } }],
{
request: { headers: [{ name: "content-type", value: "application/vnd.example+json" }] },
},
),
),
)!;
expect(result.resources.httpRequests[0]?.headers).toEqual([]);
});
});
describe("request conversion", () => {
test.each([
["json", "application/json", "application/json"],
["xml", "application/xml", "application/xml"],
["text", "other", "text/plain"],
["sparql", "other", "application/sparql-query"],
])("imports %s bodies with a default content type", (type, bodyType, contentType) => {
expect(body({ type, data: "{{value}}" }, false, [])).toEqual({
bodyType,
body: { text: "${[ value ]}" },
headers: [{ name: "Content-Type", value: contentType, enabled: true }],
});
});
test("preserves explicit and disabled Content-Type headers", () => {
expect(
body({ type: "json", data: "{}" }, false, [
{ name: "CONTENT-type", value: "custom", disabled: true },
]).headers,
).toEqual([{ name: "CONTENT-type", value: "custom", enabled: false }]);
});
test("imports form fields and the selected file or body variant", () => {
expect(
body(
{ type: "form-urlencoded", data: [{ name: "field", value: "{{value}}", disabled: true }] },
false,
[],
).body,
).toEqual({ form: [{ name: "field", value: "${[ value ]}", enabled: false }] });
expect(
body(
{
type: "file",
data: [
{ filePath: "wrong", selected: false },
{ filePath: "./correct.bin", selected: true, contentType: "application/octet-stream" },
],
},
false,
[],
),
).toMatchObject({ bodyType: "binary", body: { filePath: "./correct.bin" } });
expect(
body(
[
{ title: "A", body: { type: "json", data: "a" } },
{ title: "B", selected: true, body: { type: "json", data: "b" } },
],
false,
[],
).body,
).toEqual({ text: "b" });
expect(
body({ type: "file", data: [{ filePath: "disabled", selected: false }] }, false, []).bodyType,
).toBeNull();
});
test("imports raw GraphQL variables without parsing expressions and preserves operationName", () => {
expect(
body(
{ query: "query Get { me }", variables: '{"id": {{id}}}', operationName: "Get" },
true,
[],
).body,
).toEqual({ query: "query Get { me }", variables: '{"id": ${[ id ]}}', operationName: "Get" });
expect(body({ query: "{ me }", variables: { id: "{{id}}" } }, true, []).body.variables).toBe(
'{\n "id": "${[ id ]}"\n}',
);
});
test("avoids duplicate encoded query keys without losing unrelated query entries or fragments", () => {
expect(
urlAndParams("{{host}}/a?some%20key=old&some+key=old&keep=%2F#part", [
{ type: "query", name: "some key", value: "new" },
]),
).toMatchObject({
url: "${[ host ]}/a?keep=%2F#part",
urlParameters: [{ name: "some key", value: "new" }],
});
expect(urlAndParams("{{host}}/a?x=1", []).url).toBe("${[ host ]}/a?x=1");
});
test("converts plain variables and preserves unsupported expressions", () => {
expect(
template(
"{{ host }} {{user.id}} {{process.env.TOKEN}} {{$randomUUID}} {{a + b}} {{true}} {{null}}",
),
).toBe(
"${[ host ]} ${[ user.id ]} {{process.env.TOKEN}} {{$randomUUID}} {{a + b}} {{true}} {{null}}",
);
expect(
variables([
{ name: "secret", secret: true, value: "must not import" },
{
name: "variant",
value: [{ value: "a" }, { selected: true, value: { type: "object", data: '{"x":1}' } }],
},
]),
).toEqual([
{ name: "secret", value: "", enabled: true },
{ name: "variant", value: '{"x":1}', enabled: true },
]);
});
});
describe("authentication", () => {
test("distinguishes request no-auth from explicit and folder inheritance", () => {
expect(auth(undefined).authenticationType).toBe("none");
expect(auth("inherit").authenticationType).toBeNull();
expect(auth(undefined, true).authenticationType).toBeNull();
});
test.each([
["basic", "basic"],
["digest", "digest"],
["ntlm", "windows"],
["bearer", "bearer"],
["awsv4", "awsv4"],
])("imports %s auth", (type, expected) => {
expect(auth({ type, username: "{{user}}", token: "{{token}}" }).authenticationType).toBe(
expected,
);
});
test("maps API key placement and OAuth client credentials/PKCE", () => {
expect(auth({ type: "apikey", key: "key", value: "{{token}}", placement: "query" })).toEqual({
authenticationType: "apikey",
authentication: { key: "key", value: "${[ token ]}", location: "query" },
});
expect(
auth({
type: "oauth2",
flow: "authorization_code",
credentials: { clientId: "{{client}}", placement: "basic_auth_header" },
callbackUrl: "http://localhost/callback",
pkce: { method: "S256" },
tokenConfig: { placement: { header: "Token" } },
}).authentication,
).toMatchObject({
grantType: "authorization_code",
clientId: "${[ client ]}",
credentials: "basic",
redirectUri: "http://localhost/callback",
usePkce: true,
pkceChallengeMethod: "S256",
headerPrefix: "Token",
});
expect(
auth({
type: "oauth2",
flow: "resource_owner_password_credentials",
resourceOwner: { username: "a", password: "b" },
}).authentication,
).toMatchObject({ grantType: "password", username: "a", password: "b" });
expect(auth({ type: "oauth2", flow: "client_credentials" }).authentication).toMatchObject({
grantType: "client_credentials",
credentials: "body",
usePkce: false,
});
});
test.each([
{ type: "awsv4", profileName: "local" },
{ type: "oauth2", flow: "client_credentials", tokenConfig: { placement: { query: "token" } } },
{ type: "oauth2", flow: "unknown" },
{ type: "wsse" },
])("does not silently inherit auth for unsupported configurations", (value) => {
expect(auth(value).authenticationType).toBe("none");
});
});
describe("scope and unsupported-field regressions", () => {
test("flattens multi-level environments without disabled overrides hiding inherited values", () => {
const environments = [
{
name: "Child",
extends: "Middle",
variables: [
{ name: "a", value: "disabled", disabled: true },
{ name: "b", value: "child" },
],
},
{ name: "Middle", extends: "Root", variables: [{ name: "a", value: "middle" }] },
{
name: "Root",
variables: [
{ name: "a", value: "root" },
{ name: "b", value: "root" },
{ name: "off", value: "ignored", disabled: true },
],
},
];
const result = convertBruno(JSON.stringify(collection([], { config: { environments } })))!;
const child = result.resources.environments[1]!;
expect(child.parentId).toBeNull();
expect(child.variables).toEqual([
{ name: "a", value: "middle", enabled: true },
{ name: "a", value: "disabled", enabled: false },
{ name: "b", value: "child", enabled: true },
]);
});
test("distinguishes an empty folder from folder request defaults with no auth", () => {
const result = convertBruno(
JSON.stringify(
collection([
{ info: { type: "folder", name: "Empty" } },
{ info: { type: "folder", name: "No auth" }, request: { headers: [] } },
{ info: { type: "folder", name: "Inherited" }, request: { auth: "inherit" } },
]),
),
)!;
expect(result.resources.folders.map((f) => f.authenticationType)).toEqual([null, "none", null]);
});
test("retains external environment settings and unknown bodies, and does not turn binary WS data into text", () => {
const result = convertBruno(
JSON.stringify(
collection(
[
{
info: { type: "http", name: "Unknown body" },
http: { body: { type: "future", data: "original" } },
},
{
info: { type: "websocket", name: "Binary" },
websocket: { message: { type: "binary", data: "AA==" } },
},
],
{
config: {
environments: [
{
name: "Vault",
externalSecrets: { type: "aws-secrets-manager" },
dotEnvFilePath: "./.env",
},
],
},
},
),
),
)!;
expect(result.resources.workspaces[0]?.description).toContain("aws-secrets-manager");
expect(result.resources.workspaces[0]?.description).toContain("./.env");
expect(result.resources.httpRequests[0]?.description).toContain("original");
expect(result.resources.websocketRequests[0]?.message).toBe("");
expect(result.resources.websocketRequests[0]?.description).toContain("AA==");
});
test("maps OAuth 1.0 credentials and keeps unsupported private-key files inactive", () => {
expect(
auth({
type: "oauth1",
consumerKey: "{{key}}",
accessToken: "token",
accessTokenSecret: "secret",
privateKey: { type: "text", value: "pem" },
}),
).toMatchObject({
authenticationType: "oauth1",
authentication: {
consumerKey: "${[ key ]}",
tokenKey: "token",
tokenSecret: "secret",
privateKey: "pem",
signatureMethod: "HMAC-SHA1",
},
});
expect(
auth({ type: "oauth1", privateKey: { type: "file", value: "./key.pem" } }).authenticationType,
).toBe("none");
expect(auth({ type: "oauth1", placement: "query" }).authenticationType).toBe("none");
});
});
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more