From aa10c44a456b49494e2de53132a5b5e51fa07ab3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 24 Sep 2026 14:50:44 -0700 Subject: [PATCH] Add Bruno imports and a file-tree importer API (#706) --- .oxfmtrc.json | 3 +- .../components/CloneGitRepositoryDialog.tsx | 27 +- .../components/CreateEnvironmentDialog.tsx | 23 +- .../components/CreateWorkspaceDialog.tsx | 11 +- .../components/ExportDataDialog.tsx | 43 +- apps/yaak-client/components/GrpcEditor.tsx | 1 + .../components/GrpcProtoSelectionDialog.tsx | 155 ++--- .../components/ImportDataDialog.tsx | 646 ++++++++--------- .../components/ImportSourceList.tsx | 271 ++++++++ apps/yaak-client/components/Onboarding.tsx | 2 +- .../components/SwitchWorkspaceDialog.tsx | 56 +- .../components/TemplateFunctionDialog.tsx | 133 ++-- apps/yaak-client/components/core/Chip.tsx | 32 + apps/yaak-client/components/core/Confirm.tsx | 29 +- apps/yaak-client/components/core/Dialog.tsx | 77 +- apps/yaak-client/components/core/PathList.tsx | 123 ++++ apps/yaak-client/components/core/Prompt.tsx | 23 +- .../components/git/BranchSelectionDialog.tsx | 43 -- .../components/git/GitCommitDialog.tsx | 45 +- apps/yaak-client/components/git/diverged.tsx | 30 +- apps/yaak-client/hooks/useExportData.tsx | 1 - apps/yaak-client/lib/errorMessage.ts | 3 + apps/yaak-client/lib/graphqlSchema.ts | 5 +- apps/yaak-client/lib/importData.tsx | 24 +- crates-cli/yaak-cli/skills/use-yaak/SKILL.md | 8 +- crates-cli/yaak-cli/src/cli.rs | 4 +- .../yaak-cli/src/commands/import_export.rs | 23 +- crates-tauri/yaak-app-client/src/import.rs | 124 +++- crates-tauri/yaak-app-client/src/lib.rs | 15 +- crates-tauri/yaak-app-client/src/rpc_ext.rs | 14 +- .../yaak-rpc-schema/bindings/gen_rpc.ts | 8 +- .../yaak-rpc-schema/bindings/gen_util.ts | 12 + crates/common/yaak-rpc-schema/src/lib.rs | 23 +- crates/yaak-models/bindings/gen_util.ts | 12 + crates/yaak-models/src/util.rs | 19 +- crates/yaak-plugins/bindings/gen_events.ts | 11 +- crates/yaak-plugins/src/events.rs | 13 + crates/yaak-plugins/src/import_input.rs | 79 +++ crates/yaak-plugins/src/lib.rs | 2 + crates/yaak-plugins/src/manager.rs | 23 +- crates/yaak/src/import.rs | 658 +++++++++++++++++- package-lock.json | 111 ++- package.json | 1 + packages/platform/src/web/commands.ts | 2 +- packages/plugin-runtime-types/IMPORTERS.md | 40 ++ .../src/bindings/gen_events.ts | 11 +- packages/plugin-runtime-types/src/index.ts | 8 +- .../src/plugins/ImporterPlugin.ts | 42 +- packages/plugin-runtime/package.json | 4 +- packages/plugin-runtime/src/PluginInstance.ts | 27 +- .../src/fixtures/import-duplicate.zip | Bin 0 -> 201 bytes .../src/fixtures/import-files.zip | Bin 0 -> 506 bytes .../src/fixtures/import-large.zip | Bin 0 -> 33764 bytes .../src/fixtures/import-symlink.zip | Bin 0 -> 114 bytes .../src/fixtures/import-traversal.zip | Bin 0 -> 128 bytes .../plugin-runtime/src/fixtures/wrapped.zip | Bin 0 -> 1126 bytes .../plugin-runtime/src/importFiles.test.ts | 224 ++++++ packages/plugin-runtime/src/importFiles.ts | 326 +++++++++ packages/ui/src/components/Button.tsx | 2 + plugins/importer-bruno/README.md | 27 + plugins/importer-bruno/package.json | 16 + plugins/importer-bruno/src/auth.ts | 118 ++++ plugins/importer-bruno/src/body.ts | 109 +++ plugins/importer-bruno/src/common.ts | 69 ++ plugins/importer-bruno/src/environments.ts | 39 ++ plugins/importer-bruno/src/files.ts | 187 +++++ plugins/importer-bruno/src/index.ts | 227 ++++++ plugins/importer-bruno/tests/files.test.ts | 118 ++++ .../tests/fixtures/collection.yaml | 136 ++++ .../importer-bruno/tests/fixtures/legacy.zip | Bin 0 -> 1108 bytes .../fixtures/legacy/Demo/Users/folder.bru | 4 + .../tests/fixtures/legacy/Demo/Users/get.bru | 10 + .../fixtures/legacy/Demo/Users/query.bru | 13 + .../tests/fixtures/legacy/Demo/bruno.json | 1 + .../tests/fixtures/legacy/Demo/collection.bru | 9 + .../legacy/Demo/environments/Local.bru | 3 + .../tests/fixtures/opencollection.zip | Bin 0 -> 1041 bytes .../opencollection/Demo/Users/folder.yml | 6 + .../opencollection/Demo/Users/get.yml | 8 + .../Demo/Users/nested/graphql.yaml | 7 + .../Demo/environments/Local.yml | 4 + .../opencollection/Demo/opencollection.yml | 7 + .../tests/fixtures/upstream/LICENSE.md | 22 + .../tests/fixtures/upstream/README.md | 16 + .../upstream/bru-migration/api/folder.bru | 7 + .../upstream/bru-migration/api/get-users.bru | 24 + .../upstream/bru-migration/bruno.json | 10 + .../upstream/bru-migration/collection.bru | 13 + .../bru-migration/environments/Local.bru | 3 + .../bru-migration/environments/Production.bru | 3 + .../fixtures/upstream/bru-migration/ping.bru | 15 + .../upstream/bru-migration/post-json.bru | 25 + .../upstream/yaml-docs/folder_1/folder.yml | 4 + .../upstream/yaml-docs/folder_1/request_1.yml | 8 + .../upstream/yaml-docs/folder_2/folder.yml | 4 + .../upstream/yaml-docs/folder_2/request_1.yml | 8 + .../upstream/yaml-docs/opencollection.yml | 4 + .../fixtures/upstream/yaml-docs/request_1.yml | 8 + .../fixtures/upstream/yaml-docs/request_2.yml | 8 + plugins/importer-bruno/tests/index.test.ts | 459 ++++++++++++ plugins/importer-bruno/tests/upstream.test.ts | 108 +++ plugins/importer-bruno/tsconfig.json | 3 + plugins/importer-curl/src/index.ts | 6 +- plugins/importer-insomnia/src/index.ts | 7 +- plugins/importer-openapi/src/index.ts | 6 +- .../importer-postman-environment/src/index.ts | 6 +- plugins/importer-postman/src/index.ts | 6 +- plugins/importer-yaak/src/index.ts | 5 +- vite.config.ts | 5 +- 109 files changed, 4684 insertions(+), 879 deletions(-) create mode 100644 apps/yaak-client/components/ImportSourceList.tsx create mode 100644 apps/yaak-client/components/core/Chip.tsx create mode 100644 apps/yaak-client/components/core/PathList.tsx delete mode 100644 apps/yaak-client/components/git/BranchSelectionDialog.tsx create mode 100644 apps/yaak-client/lib/errorMessage.ts create mode 100644 crates/yaak-plugins/src/import_input.rs create mode 100644 packages/plugin-runtime-types/IMPORTERS.md create mode 100644 packages/plugin-runtime/src/fixtures/import-duplicate.zip create mode 100644 packages/plugin-runtime/src/fixtures/import-files.zip create mode 100644 packages/plugin-runtime/src/fixtures/import-large.zip create mode 100644 packages/plugin-runtime/src/fixtures/import-symlink.zip create mode 100644 packages/plugin-runtime/src/fixtures/import-traversal.zip create mode 100644 packages/plugin-runtime/src/fixtures/wrapped.zip create mode 100644 packages/plugin-runtime/src/importFiles.test.ts create mode 100644 packages/plugin-runtime/src/importFiles.ts create mode 100644 plugins/importer-bruno/README.md create mode 100644 plugins/importer-bruno/package.json create mode 100644 plugins/importer-bruno/src/auth.ts create mode 100644 plugins/importer-bruno/src/body.ts create mode 100644 plugins/importer-bruno/src/common.ts create mode 100644 plugins/importer-bruno/src/environments.ts create mode 100644 plugins/importer-bruno/src/files.ts create mode 100644 plugins/importer-bruno/src/index.ts create mode 100644 plugins/importer-bruno/tests/files.test.ts create mode 100644 plugins/importer-bruno/tests/fixtures/collection.yaml create mode 100644 plugins/importer-bruno/tests/fixtures/legacy.zip create mode 100644 plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/folder.bru create mode 100644 plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/get.bru create mode 100644 plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/query.bru create mode 100644 plugins/importer-bruno/tests/fixtures/legacy/Demo/bruno.json create mode 100644 plugins/importer-bruno/tests/fixtures/legacy/Demo/collection.bru create mode 100644 plugins/importer-bruno/tests/fixtures/legacy/Demo/environments/Local.bru create mode 100644 plugins/importer-bruno/tests/fixtures/opencollection.zip create mode 100644 plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/folder.yml create mode 100644 plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/get.yml create mode 100644 plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/nested/graphql.yaml create mode 100644 plugins/importer-bruno/tests/fixtures/opencollection/Demo/environments/Local.yml create mode 100644 plugins/importer-bruno/tests/fixtures/opencollection/Demo/opencollection.yml create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/LICENSE.md create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/README.md create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/folder.bru create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/get-users.bru create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/bruno.json create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/collection.bru create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Local.bru create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Production.bru create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/ping.bru create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/bru-migration/post-json.bru create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/folder.yml create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/request_1.yml create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/folder.yml create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/request_1.yml create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/opencollection.yml create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_1.yml create mode 100644 plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_2.yml create mode 100644 plugins/importer-bruno/tests/index.test.ts create mode 100644 plugins/importer-bruno/tests/upstream.test.ts create mode 100644 plugins/importer-bruno/tsconfig.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 682f3b97c..b06ff6f53 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -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/**" ] } diff --git a/apps/yaak-client/components/CloneGitRepositoryDialog.tsx b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx index 160211018..dcba92539 100644 --- a/apps/yaak-client/components/CloneGitRepositoryDialog.tsx +++ b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx @@ -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(""); const [isCloning, setIsCloning] = useState(false); const [error, setError] = useState(null); + const formId = useId(); const repoName = extractRepoName(url); const sep = getPathSeparator(baseDirectory); @@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) { }; return ( - + {error && ( {error} @@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) { /> )} - + ); } diff --git a/apps/yaak-client/components/CreateEnvironmentDialog.tsx b/apps/yaak-client/components/CreateEnvironmentDialog.tsx index 1436617a1..c4e1fd7a0 100644 --- a/apps/yaak-client/components/CreateEnvironmentDialog.tsx +++ b/apps/yaak-client/components/CreateEnvironmentDialog.tsx @@ -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(""); const [color, setColor] = useState(null); const [sharable, toggleSharable] = useToggle(false); + const formId = useId(); return (
{ e.preventDefault(); const id = await createWorkspaceModel({ @@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) - + : undefined, + }, + ]} + /> ); } diff --git a/apps/yaak-client/components/CreateWorkspaceDialog.tsx b/apps/yaak-client/components/CreateWorkspaceDialog.tsx index 25e736127..71154fbbb 100644 --- a/apps/yaak-client/components/CreateWorkspaceDialog.tsx +++ b/apps/yaak-client/components/CreateWorkspaceDialog.tsx @@ -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(false); + const formId = useId(); return ( { e.preventDefault(); const workspaceId = await createGlobalModel({ model: "workspace", name }); @@ -89,9 +90,7 @@ export function CreateWorkspaceDialog({ hide }: Props) { title="Enable Encryption" /> - + ); } diff --git a/apps/yaak-client/components/ExportDataDialog.tsx b/apps/yaak-client/components/ExportDataDialog.tsx index 11f4bd21c..d0d89c3ee 100644 --- a/apps/yaak-client/components/ExportDataDialog.tsx +++ b/apps/yaak-client/components/ExportDataDialog.tsx @@ -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 ( -
- + <> + @@ -141,29 +141,22 @@ function ExportDataDialogContent({ /> -
-
+ Create Run Button -
- - - - -
- + } + actions={[ + { label: "Cancel", onClick: onHide }, + { + label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`, + color: "primary", + disabled: noneSelected, + onClick: () => handleExport(), + }, + ]} + /> + ); } diff --git a/apps/yaak-client/components/GrpcEditor.tsx b/apps/yaak-client/components/GrpcEditor.tsx index e44fc1f22..e1fcb1dbe 100644 --- a/apps/yaak-client/components/GrpcEditor.tsx +++ b/apps/yaak-client/components/GrpcEditor.tsx @@ -239,6 +239,7 @@ export function GrpcEditor({ showDialog({ title: "Configure Schema", size: "md", + className: "h-[30rem]", id: "grpc-configure-schema", render: ({ hide }) => , }); diff --git a/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx b/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx index cafa422ed..dab6c4f82 100644 --- a/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx +++ b/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx @@ -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 ; } -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 ( - - {/* Buttons on top so they get focus first */} - - - - - - + <> + {reflectError && (

@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp )} - {protoFiles.length > 0 && ( -

- - - - - - - {protoFiles.map((f, i) => { - const parts = f.split("/"); - // oxlint-disable-next-line no-array-index-key -- none - return ( - - - - - - ); - })} - -
- Added File Paths -
- - - {parts.length > 3 && ".../"} - {parts.slice(-3).join("/")} - - { - await protoFilesKv.set(protoFiles.filter((p) => p !== f)); - }} - /> -
- )} + ({ + 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(); + }, + }, + ]} + /> {reflectionUnimplemented && protoFiles.length === 0 && ( {request.url} doesn't implement{" "} @@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp )}
-
+ grpc.reflect.refetch(), + }, + { label: "Done", color: "primary", onClick: onDone }, + ]} + /> + ); } diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 1d755a03b..6bb850d91 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -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; - planUrl: (url: string, destination: ImportDestination) => Promise; + planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise; + detectSource: (source: ImportSourcePath) => Promise; listSources: (workspaceId: string) => Promise; findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise; commit: (plan: ImportPlan) => Promise; @@ -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 ; } -function latestSource(sources: ImportSource[]): ImportSource | null { - return sources.reduce( - (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(false); + const [sourceError, setSourceError] = useState(null); const [plan, setPlan] = useState(null); const [items, setItems] = useState([]); // null means no explicit choice yet, so the default below applies @@ -113,65 +86,82 @@ function LoadedImportDataDialog({ null, ); const [otherWorkspaceId, setOtherWorkspaceId] = useState(null); - const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null); - const [linkedSources, setLinkedSources] = useState( - prefill != null ? initialSources : [], + const [originSources, setOriginSources] = useState(initialSources); + const initialPaths = useMemo( + () => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath), + [initialSources], ); - const [originSources, setOriginSources] = useState( - prefill != null ? [prefill] : [], + const [sources, setSources] = useState(initialPaths); + const knownPaths = useRef(new Set(initialPaths.map((source) => source.path))); + const [detections, setDetections] = useState>({}); + 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, + ); + detectSource(source).then( + (importer) => settle({ status: "ok", importer }), + (err) => settle({ status: "error", message: errorMessage(err) }), + ); + } + }, + [detectSource], ); - // A file path or a URL. Both inputs write here, so there is only ever one thing to import - const [source, setSource] = useState(prefill?.origin ?? null); - const [forceUpdateKey, setForceUpdateKey] = useState(0); - const [isHovering, setIsHovering] = useState(false); - const ref = useRef(null); - const trimmedSource = source?.trim() ?? ""; - const filePath = isFilePath(trimmedSource) ? trimmedSource : null; + const 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); - }) - .catch(() => setOriginSources([])); - }, 300); + Promise.all( + sources.map(({ path, kind }) => + findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }), + ), + ) + .then((found) => { + if (!cancelled) setOriginSources(found.flat()); + }) + .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,196 +272,215 @@ 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(", ")]; + return [ + names.length === 1 ? "New workspace" : `${names.length} new workspaces`, + names.join(", "), + ]; } - const { workspaceId, folderId } = plan.destination; + const { workspaceId } = plan.destination; const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; - return [ - "Destination", - folderId != null && folderId === selectedFolder?.id - ? `${name} / ${selectedFolder.name}` - : name, - ]; + 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 = (() => { - const planned = plan.resources.workspaces[0]; + const workspaceRoots: CheckboxTreeNode[] = (() => { 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: workspace.name, + isNew: planDestination.type === "new_workspace", + }, + 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: existing?.name ?? planned?.name ?? "New workspace", + label: workspace.name, isNew: planDestination.type === "new_workspace", }, - children: itemTree, - }; + children: buildItemTree( + items.filter((item) => workspaceById.get(item.modelId) === workspace.id), + ), + })); })(); return ( - -
- - -
+ <> + +
+ + +
- {plan.warnings.map((warning) => ( - - -
-
{warning.title}
-
{warning.detail}
+ {plan.warnings + .filter( + (warning, index, warnings) => + warnings.findIndex( + (other) => + other.title === warning.title && + other.detail === warning.detail && + other.level === warning.level, + ) === index, + ) + .map((warning) => ( + + +
+
{warning.title}
+
{warning.detail}
+
+
+ ))} + + {workspaceRoots.map((root) => ( +
+ disabledIds.has(n.key)} + 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) => ( + + )} + />
- - ))} - -
- disabledIds.has(n.key)} - isCollapsedByDefault={(n) => 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) => } - /> -
- - - {footerNote !== "" &&
{footerNote}
} - - -
- + ))} + + {footerNote}
+ ) : undefined + } + actions={[ + { + label: "Back", + disabled: isLoading, + onClick: () => { + setPlan(null); + setItems([]); + }, + }, + { + label: isLoading + ? "Importing" + : changeCount > 0 + ? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}` + : "Done", + color: "primary", + isLoading, + onClick: handleCommit, + }, + ]} + /> + ); } - const lastImported = - originSources.find((s) => s.workspaceId === destinationWorkspaceId) ?? - linkedSources.reduce( - (latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), - null, - ); - - return ( - - - - - - + setOtherWorkspaceId(id === "" ? null : id)} + filterable options={[ - { value: "new", label: "New Workspace" }, - ...(currentWorkspace != null - ? [{ value: "current" as const, label: "Current Workspace" }] - : []), - { value: "other", label: "Other Workspace" }, + { value: "", label: "Select a workspace" }, + ...workspaces + .filter((w) => w.id !== currentWorkspace?.id) + .map((w) => ({ value: w.id, label: w.name })), ]} /> - {destinationKind === "other" && ( - ({ label: b, value: b }))} - onChange={setBranch} - /> - - - - - - ); -} diff --git a/apps/yaak-client/components/git/GitCommitDialog.tsx b/apps/yaak-client/components/git/GitCommitDialog.tsx index 08aa163c5..c0e77555d 100644 --- a/apps/yaak-client/components/git/GitCommitDialog.tsx +++ b/apps/yaak-client/components/git/GitCommitDialog.tsx @@ -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 }) { )}
{resolvedModelName(node.model)}
{node.status.status !== "current" && ( - - {node.status.status} - + {node.status.status} )} ); @@ -376,16 +367,9 @@ function ExternalTreeNode({
{displayPath}
- + {entry.status} - +
} /> @@ -479,3 +463,20 @@ function DiffPanel({
); } + +function statusColor(status: GitStatus): ComponentProps["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"; + } +} diff --git a/apps/yaak-client/components/git/diverged.tsx b/apps/yaak-client/components/git/diverged.tsx index 26d778ab1..2ac00ae69 100644 --- a/apps/yaak-client/components/git/diverged.tsx +++ b/apps/yaak-client/components/git/diverged.tsx @@ -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 ( -
+

Your local branch has diverged from{" "} @@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp }, ]} /> - - - - +

); } diff --git a/apps/yaak-client/hooks/useExportData.tsx b/apps/yaak-client/hooks/useExportData.tsx index 8cf8ebba4..dba222447 100644 --- a/apps/yaak-client/hooks/useExportData.tsx +++ b/apps/yaak-client/hooks/useExportData.tsx @@ -23,7 +23,6 @@ export function useExportData() { id: "export-data", title: "Export Data", size: "md", - noPadding: true, render: ({ hide }) => ( - rpc("cmd_import_data", { filePath, destination }); -const planUrl = (url: string, destination: ImportDestination) => - rpc("cmd_import_url", { url, destination }); +const planSources = (sources: ImportSourcePath[], destination: ImportDestination) => + rpc("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( + "cmd_detect_import_source", + source.kind === "url" ? { url: source.path } : { filePath: source.path }, + ); const listSources = (workspaceId: string) => rpc("cmd_list_import_sources", { workspaceId }); const findSourcesForOrigin = (args: { filePath?: string; url?: string }) => @@ -43,11 +50,11 @@ export const importData = createFastMutation({ return new Promise((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({ 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 ` 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 ` 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 diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index b4754ac62..4589936fd 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -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 diff --git a/crates-cli/yaak-cli/src/commands/import_export.rs b/crates-cli/yaak-cli/src/commands/import_export.rs index abd534e2f..89f60a7b3 100644 --- a/crates-cli/yaak-cli/src/commands/import_export.rs +++ b/crates-cli/yaak-cli/src/commands/import_export.rs @@ -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 = std::result::Result; @@ -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 { - 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| { diff --git a/crates-tauri/yaak-app-client/src/import.rs b/crates-tauri/yaak-app-client/src/import.rs index 127f3eecd..19cc8e320 100644 --- a/crates-tauri/yaak-app-client/src/import.rs +++ b/crates-tauri/yaak-app-client/src/import.rs @@ -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( window: &WebviewWindow, file_path: &str, origin: Option, ) -> Result { - 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( window: &WebviewWindow, - file_path: &str, + file_paths: &[String], + urls: &[String], destination: ImportDestination, ) -> Result { - 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( +/// 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( window: &WebviewWindow, - url: &str, - destination: ImportDestination, -) -> Result { - 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, + url: Option, +) -> Result { + 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( window: &WebviewWindow, - contents: &str, + input: &ImportRequest, destination: ImportDestination, origin: Option, ) -> Result { @@ -53,7 +96,7 @@ async fn plan_import_contents( plugin_manager: &plugin_manager, plugin_context: &plugin_context, destination, - contents, + input, origin, }) .await?) @@ -88,7 +131,10 @@ pub(crate) fn commit_import( /// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy, /// client certificate, or certificate-validation settings. Requests are unauthenticated, so /// specs behind auth must still be downloaded manually and imported as a file. -async fn fetch_import_url(window: &WebviewWindow, url: &str) -> Result { +async fn fetch_import_url( + window: &WebviewWindow, + url: &str, +) -> Result { let url = normalize_import_url(url)?; let app_version = window.app_handle().package_info().version.to_string(); let response = yaak_api_client(ApiClientKind::App, &app_version)? @@ -104,10 +150,27 @@ async fn fetch_import_url(window: &WebviewWindow, 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 { @@ -128,15 +191,9 @@ pub(crate) fn normalize_import_url(url: &str) -> Result { } } -fn read_import_file(file_path: &str) -> Result { - 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::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"); } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index e8c301eca..0ba49907c 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -4,7 +4,7 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::grpc::{build_metadata, metadata_to_map}; 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( async fn cmd_import_data( window: WebviewWindow, - file_path: &str, + file_paths: &[String], + urls: &[String], destination: ImportDestination, ) -> YaakResult { - plan_import_data(&window, file_path, destination).await -} - -async fn cmd_import_url( - window: WebviewWindow, - url: &str, - destination: ImportDestination, -) -> YaakResult { - plan_import_url(&window, url, destination).await + plan_import_data(&window, file_paths, urls, destination).await } async fn cmd_commit_import( diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index 1e459704b..e62232f74 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -517,11 +517,8 @@ async fn cmd_import_data( ctx: ClientCtx, req: CmdImportDataReq, ) -> Result { - Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?) -} - -async fn cmd_import_url(ctx: ClientCtx, req: CmdImportUrlReq) -> Result { - 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( @@ -539,6 +536,13 @@ async fn cmd_list_import_sources( Ok(ctx.window.db().list_import_sources(&req.workspace_id)?) } +async fn cmd_detect_import_source( + ctx: ClientCtx, + req: CmdDetectImportSourceReq, +) -> Result { + Ok(crate::import::detect_import_source(&ctx.window, req.file_path, req.url).await?) +} + async fn cmd_import_sources_for_origin( ctx: ClientCtx, req: CmdImportSourcesForOriginReq, diff --git a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts index 49eb2d2db..e0718d686 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts @@ -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, urls: Array, 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; @@ -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], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, 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], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; +export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, 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], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; export type WatchResult = { unlistenEvent: string, }; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_util.ts b/crates/common/yaak-rpc-schema/bindings/gen_util.ts index 3217af234..77f4599bb 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_util.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_util.ts @@ -54,6 +54,10 @@ export type ImportPlan = { */ items: Array; origin?: ImportOrigin; + /** + * Individual origins in a combined import, each retaining its own reimport history. + */ + sources: Array; }; 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 }; /** diff --git a/crates/common/yaak-rpc-schema/src/lib.rs b/crates/common/yaak-rpc-schema/src/lib.rs index 75d4876db..8f6d8ad83 100644 --- a/crates/common/yaak-rpc-schema/src/lib.rs +++ b/crates/common/yaak-rpc-schema/src/lib.rs @@ -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, + pub urls: Vec, pub destination: ImportDestination, } @@ -264,6 +257,16 @@ pub struct CmdImportSourcesForOriginReq { pub url: Option, } +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CmdDetectImportSourceReq { + #[ts(optional)] + pub file_path: Option, + #[ts(optional)] + pub url: Option, +} + #[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, cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec, cmd_import_data(CmdImportDataReq) -> ImportPlan, - cmd_import_url(CmdImportUrlReq) -> ImportPlan, cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult, cmd_list_import_sources(CmdListImportSourcesReq) -> Vec, cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec, + cmd_detect_import_source(CmdDetectImportSourceReq) -> String, cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec, cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec, cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (), diff --git a/crates/yaak-models/bindings/gen_util.ts b/crates/yaak-models/bindings/gen_util.ts index 3217af234..77f4599bb 100644 --- a/crates/yaak-models/bindings/gen_util.ts +++ b/crates/yaak-models/bindings/gen_util.ts @@ -54,6 +54,10 @@ export type ImportPlan = { */ items: Array; origin?: ImportOrigin; + /** + * Individual origins in a combined import, each retaining its own reimport history. + */ + sources: Array; }; 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 }; /** diff --git a/crates/yaak-models/src/util.rs b/crates/yaak-models/src/util.rs index e90dcc5bb..afd4d1f87 100644 --- a/crates/yaak-models/src/util.rs +++ b/crates/yaak-models/src/util.rs @@ -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, } +#[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, + #[serde(default)] + #[ts(optional)] + pub linked_source_id: Option, +} + #[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, + + /// Individual origins in a combined import, each retaining its own reimport history. + #[serde(default)] + pub sources: Vec, } pub fn get_workspace_export_resources( diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index ae5642216..49f5c11d3 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -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, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; diff --git a/crates/yaak-plugins/src/events.rs b/crates/yaak-plugins/src/events.rs index 345620fe7..e0baafb69 100644 --- a/crates/yaak-plugins/src/events.rs +++ b/crates/yaak-plugins/src/events.rs @@ -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, +} + +/// 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)] diff --git a/crates/yaak-plugins/src/import_input.rs b/crates/yaak-plugins/src/import_input.rs new file mode 100644 index 000000000..b3ea81df1 --- /dev/null +++ b/crates/yaak-plugins/src/import_input.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/crates/yaak-plugins/src/lib.rs b/crates/yaak-plugins/src/lib.rs index 510756d0a..fcbc385c5 100644 --- a/crates/yaak-plugins/src/lib.rs +++ b/crates/yaak-plugins/src/lib.rs @@ -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; diff --git a/crates/yaak-plugins/src/manager.rs b/crates/yaak-plugins/src/manager.rs index 310a1a5bb..74f62333a 100644 --- a/crates/yaak-plugins/src/manager.rs +++ b/crates/yaak-plugins/src/manager.rs @@ -1091,18 +1091,32 @@ impl PluginManager { &self, plugin_context: &PluginContext, content: &str, + ) -> Result { + self.import_input(plugin_context, &ImportRequest::from_text(content)).await + } + + pub async fn import_input( + &self, + plugin_context: &PluginContext, + input: &ImportRequest, ) -> Result { 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::>() + .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), } } diff --git a/crates/yaak/src/import.rs b/crates/yaak/src/import.rs index 6d66f5fcc..9b59756c5 100644 --- a/crates/yaak/src/import.rs +++ b/crates/yaak/src/import.rs @@ -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, } /// Parse importer output and turn it into a commit-ready plan without mutating the database. pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result { 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, +) -> Result { + 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::>(); + 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::>(); - 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, 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 = 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, + upserted: &BatchUpsertResult, +) -> Result<()> { + let incoming_keys: BTreeSet = 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| -> 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(¤t) != 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 { } /// 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::().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::>(); + 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::>(), + 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::>(); + 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::>(), + 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!["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) = diff --git a/package-lock.json b/package-lock.json index b27d2b70a..88b4b6c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1b828784b..aaa0f66b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts index 8218faf45..61f613e6c 100644 --- a/packages/platform/src/web/commands.ts +++ b/packages/platform/src/web/commands.ts @@ -288,10 +288,10 @@ const DECLINED: Partial, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; diff --git a/packages/plugin-runtime-types/src/index.ts b/packages/plugin-runtime-types/src/index.ts index 8ef2c4637..45b0eb5c4 100644 --- a/packages/plugin-runtime-types/src/index.ts +++ b/packages/plugin-runtime-types/src/index.ts @@ -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"; diff --git a/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts b/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts index 6a553f240..e8687f473 100644 --- a/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts +++ b/packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts @@ -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; + readFile(path: string): Promise; + /** Decode UTF-8 strictly; binary or invalid UTF-8 content throws. */ + readTextFile(path: string): Promise; +} + +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; + +export type ImportSourceHandler = ( + ctx: Context, + args: { source: ImportSource }, +) => MaybePromise; + export type ImporterPlugin = { name: string; description?: string; - onImport( - ctx: Context, - args: { text: string }, - ): MaybePromise; + /** 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; }; diff --git a/packages/plugin-runtime/package.json b/packages/plugin-runtime/package.json index 83696e613..921303464 100644 --- a/packages/plugin-runtime/package.json +++ b/packages/plugin-runtime/package.json @@ -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" } } diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index f93b26079..3c602516d 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -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( - context, - { type: "read_http_response_body_chunk_request", responseId, offset, length }, - ); + const chunk = await this.#sendForReply(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); diff --git a/packages/plugin-runtime/src/fixtures/import-duplicate.zip b/packages/plugin-runtime/src/fixtures/import-duplicate.zip new file mode 100644 index 0000000000000000000000000000000000000000..29717606627a53c54d0a8697c105db0193ba6d80 GIT binary patch literal 201 zcmWIWW@Zs#0D)Bz=CR@L3Ma7w*&xgU#KnoZscD%-#U%kK$}$Dzx3K}GK^Ug2I5jyx yFD1a6kx7IZw}C(nV9>w_qCm!@>p(XfqMm`Ffl(cGj6pIGZ+~DF9fkb UIs?2}*+6VYAT$EfS|APs0OU0fga7~l literal 0 HcmV?d00001 diff --git a/packages/plugin-runtime/src/fixtures/import-traversal.zip b/packages/plugin-runtime/src/fixtures/import-traversal.zip new file mode 100644 index 0000000000000000000000000000000000000000..b148d4763f10d1feaa7a30b1a2f265714b9c036f GIT binary patch literal 128 zcmWIWW@Zs#0D)Bz=CSe`tJ0Z(Y!K!HVm&?m{L+%*%#>8Ul8TbN`~Yu8CJ|=b>VaCo Zpn(xYq3RCsW@Q73G6JCuNEXCl004)X6dV8m literal 0 HcmV?d00001 diff --git a/packages/plugin-runtime/src/fixtures/wrapped.zip b/packages/plugin-runtime/src/fixtures/wrapped.zip new file mode 100644 index 0000000000000000000000000000000000000000..8f70a284695dc73c3d22201cd56a90cb397f387e GIT binary patch literal 1126 zcmWIWW@h1H00Hg@i&!uNO0Y7>Fu0`V=Ie)sa56BPrf$sw;nE6j21b^zj0_AcAawya zwe!HV=cN{xq^6KyN{=to1va4BAS{ArN?K-4s$NM&2|hPv=H;apaiO|Q=}Uzt$iX1Y z3p2AQKfi>KVI}zmXhs1|fCEmTQStG#fHxzP95b$DAOUr*0K;2H5EJT1R!E}2ND#P<;$c8G${uDEJb@5rB5Lx0 zm52WaCJR iA=E^K?CbRyW+EpkTrOq> { + 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"); + }); +}); diff --git a/packages/plugin-runtime/src/importFiles.ts b/packages/plugin-runtime/src/importFiles.ts new file mode 100644 index 000000000..20453ec3a --- /dev/null +++ b/packages/plugin-runtime/src/importFiles.ts @@ -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 { + 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([ + ["", { type: "directory" }], + ]); + if (source && isZip(bytes)) { + kind = "zip"; + text = ""; + isText = false; + zip = await new Promise((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((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((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(entries: Map) { + const tops = new Set(); + 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(); + } +} diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index bc65c8d1b..b23a6871c 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -17,6 +17,8 @@ export type ButtonProps = Omit, "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; diff --git a/plugins/importer-bruno/README.md b/plugins/importer-bruno/README.md new file mode 100644 index 000000000..39c4966e5 --- /dev/null +++ b/plugins/importer-bruno/README.md @@ -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. diff --git a/plugins/importer-bruno/package.json b/plugins/importer-bruno/package.json new file mode 100644 index 000000000..61d4565c6 --- /dev/null +++ b/plugins/importer-bruno/package.json @@ -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" + } +} diff --git a/plugins/importer-bruno/src/auth.ts b/plugins/importer-bruno/src/auth.ts new file mode 100644 index 000000000..b624f55b8 --- /dev/null +++ b/plugins/importer-bruno/src/auth.ts @@ -0,0 +1,118 @@ +import type { HttpRequest } from "@yaakapp/api"; +import { object, template, text, type Obj } from "./common"; + +type Authentication = Pick; +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; +} diff --git a/plugins/importer-bruno/src/body.ts b/plugins/importer-bruno/src/body.ts new file mode 100644 index 000000000..4fc9aa6ba --- /dev/null +++ b/plugins/importer-bruno/src/body.ts @@ -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 { + const b = object(selected(value, "body")); + const requestHeaders = headers(rawHeaders); + const result = (bodyType: string | null, data: Record, 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 = { + 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>((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, + }; +} diff --git a/plugins/importer-bruno/src/common.ts b/plugins/importer-bruno/src/common.ts new file mode 100644 index 000000000..d10b318a1 --- /dev/null +++ b/plugins/importer-bruno/src/common.ts @@ -0,0 +1,69 @@ +import type { EnvironmentVariable, HttpRequestHeader } from "@yaakapp/api"; +import YAML from "yaml"; + +export type Obj = Record; +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"); +} diff --git a/plugins/importer-bruno/src/environments.ts b/plugins/importer-bruno/src/environments.ts new file mode 100644 index 000000000..b741b0bc1 --- /dev/null +++ b/plugins/importer-bruno/src/environments.ts @@ -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()): ReturnType => { + 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, + })); +} diff --git a/plugins/importer-bruno/src/files.ts b/plugins/importer-bruno/src/files.ts new file mode 100644 index 000000000..736d9275c --- /dev/null +++ b/plugins/importer-bruno/src/files.ts @@ -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 { + const listings = new Map(); + 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 => { + 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 }, + }; +} diff --git a/plugins/importer-bruno/src/index.ts b/plugins/importer-bruno/src/index.ts new file mode 100644 index 000000000..87496de6e --- /dev/null +++ b/plugins/importer-bruno/src/index.ts @@ -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 }; +} diff --git a/plugins/importer-bruno/tests/files.test.ts b/plugins/importer-bruno/tests/files.test.ts new file mode 100644 index 000000000..dfa845df7 --- /dev/null +++ b/plugins/importer-bruno/tests/files.test.ts @@ -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(); + } +}); diff --git a/plugins/importer-bruno/tests/fixtures/collection.yaml b/plugins/importer-bruno/tests/fixtures/collection.yaml new file mode 100644 index 000000000..4fc713aec --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/collection.yaml @@ -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 diff --git a/plugins/importer-bruno/tests/fixtures/legacy.zip b/plugins/importer-bruno/tests/fixtures/legacy.zip new file mode 100644 index 0000000000000000000000000000000000000000..2b7ca6cc66f89a28618cefa3f14c0dba44f585fa GIT binary patch literal 1108 zcmWIWW@Zs#U|`^2co|_H`_%mm1@1Y&+5c1g|6*H0=c&CA!zD$dVa9j4{2t99XU?1}PlP7Cied;rvEYR3 z&ZrQe!8t%I46`6PKPM+Oxg;|`PY-C9ZkHopg8~oB`#p=~1K$1ReWj+-bu_4#^Wl4` zFJ{)(;TH}?8fx?jA6;R!NaN^?MUi@MY~M}z)K)m{$t>>WN99x_511%weEI%oZQblO zH)dwNy>M%`4XPu21h4o@11*;XVh|1XWolkoW>J1#ZfaghvA$1!a$*k1G1}fbT45`m z1qEoFJnwgQbC8DL8Ql|}Pggxe*e8vt`>27#mPSr#?r*1-u9`USs>Q@L z0Y4Sjq|Nf~UZ4L|t>gMom{`;|#n0yfLIy*qYDIDS5_*z)$SDMwY5+~>4!i;^yg zJ~-nl<-PPuix;<3wb8+2!4|<+W{9qSP%K&JyWH)4*o?|wXRhYIy>GHLg7>+sXLU=y zS=gh`{IaIOr;7S{dBi@m1$Z+ui7?|%vq0y8K?5U*LQBEun$goEL@NVB17jjgGivHY zHvl~?Aq;p1GXQ(aLN^FK0U->s0(t}~z!Av_-3asqgD|2O)d*0s literal 0 HcmV?d00001 diff --git a/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/folder.bru b/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/folder.bru new file mode 100644 index 000000000..42d539072 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/folder.bru @@ -0,0 +1,4 @@ +meta { + name: Users + seq: 3 +} diff --git a/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/get.bru b/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/get.bru new file mode 100644 index 000000000..8f1a8ead2 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/get.bru @@ -0,0 +1,10 @@ +meta { + name: Get Users + type: http + seq: 1 +} +get { + url: {{host}}/users + body: none + auth: inherit +} diff --git a/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/query.bru b/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/query.bru new file mode 100644 index 000000000..401937034 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/legacy/Demo/Users/query.bru @@ -0,0 +1,13 @@ +meta { + name: Query + type: graphql + seq: 2 +} +post { + url: {{host}}/graphql + body: graphql + auth: none +} +body:graphql { + query { users { id } } +} diff --git a/plugins/importer-bruno/tests/fixtures/legacy/Demo/bruno.json b/plugins/importer-bruno/tests/fixtures/legacy/Demo/bruno.json new file mode 100644 index 000000000..27ad5ce22 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/legacy/Demo/bruno.json @@ -0,0 +1 @@ +{ "version": "1", "name": "Legacy Bruno", "type": "collection" } diff --git a/plugins/importer-bruno/tests/fixtures/legacy/Demo/collection.bru b/plugins/importer-bruno/tests/fixtures/legacy/Demo/collection.bru new file mode 100644 index 000000000..8df18d418 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/legacy/Demo/collection.bru @@ -0,0 +1,9 @@ +vars:pre-request { + host: https://example.com +} +auth { + mode: bearer +} +auth:bearer { + token: {{token}} +} diff --git a/plugins/importer-bruno/tests/fixtures/legacy/Demo/environments/Local.bru b/plugins/importer-bruno/tests/fixtures/legacy/Demo/environments/Local.bru new file mode 100644 index 000000000..8e4de209c --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/legacy/Demo/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + token: sample +} diff --git a/plugins/importer-bruno/tests/fixtures/opencollection.zip b/plugins/importer-bruno/tests/fixtures/opencollection.zip new file mode 100644 index 0000000000000000000000000000000000000000..254b5a7520223795b9aec285da48119fbd4fe968 GIT binary patch literal 1041 zcmWIWW@Zs#U|`^2co|_H`)}@xh72ID8i>V#*d;YLUq8PfH7_|oCnq(zBr`uxuQE4B zx7(2KkO7Zt`TRrT8JE`GoFMU%QR4E64iCRM$Ml+RJ8K>LfA9CD6>5pgQX;bs96g)Y z79%gju<@7^Mi?9-v3>;$9p}GJIwjlzI?^p1d$q^q-!P>{_i8C zCVK}x;0{1>T)EtZ)22X2Spu;%%yFrCWtm0!dAX^1CB^za`N@enAP1iIKI6MJ=)*~W zzcUZDyme0b_?+_8-S#Lb;6msLX78y_(w9GD{K>_Y+G)Aeq4Cj?J56&!^cYZWbBo^? z#cN7J=Y$_E!=2!W-N@3i3Zx01;j$AcBZG6 zfGia4@#JbS;9+=Q>-vKINcOu&>|YpJl{If1HM8}2dEd9+nCJ21xfd8*#5oex8#f27 z%zAPQKGE^3tV` z5kMyt1F;;cQ}R-aOHxzx(~A-dG75pgod`@2J*PPjD2TAU58HUd;a%B8A)Z+p6945~ z-q@ahkSS3X@%p`j#Dk}+SK;s`>KYBDF^m8$xH~~wf1$eWvfz-1CVJy(L Jc|aWu3;;T+R>1%O literal 0 HcmV?d00001 diff --git a/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/folder.yml b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/folder.yml new file mode 100644 index 000000000..dbb4f89f6 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/folder.yml @@ -0,0 +1,6 @@ +info: + name: Users + type: folder + seq: 3 +request: + auth: inherit diff --git a/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/get.yml b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/get.yml new file mode 100644 index 000000000..c14909923 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/get.yml @@ -0,0 +1,8 @@ +info: + name: Get Users + type: http + seq: 1 +http: + method: GET + url: "{{host}}/users" + auth: inherit diff --git a/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/nested/graphql.yaml b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/nested/graphql.yaml new file mode 100644 index 000000000..b157e778c --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/Users/nested/graphql.yaml @@ -0,0 +1,7 @@ +info: + name: Query + type: graphql +graphql: + url: "{{host}}/graphql" + body: + query: "query { users { id } }" diff --git a/plugins/importer-bruno/tests/fixtures/opencollection/Demo/environments/Local.yml b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/environments/Local.yml new file mode 100644 index 000000000..ee28c29fa --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/environments/Local.yml @@ -0,0 +1,4 @@ +name: Local +variables: + - name: token + value: sample diff --git a/plugins/importer-bruno/tests/fixtures/opencollection/Demo/opencollection.yml b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/opencollection.yml new file mode 100644 index 000000000..19b3cc67d --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/opencollection/Demo/opencollection.yml @@ -0,0 +1,7 @@ +opencollection: 1.0.0 +info: + name: Directory YAML +request: + variables: + - name: host + value: https://example.com diff --git a/plugins/importer-bruno/tests/fixtures/upstream/LICENSE.md b/plugins/importer-bruno/tests/fixtures/upstream/LICENSE.md new file mode 100644 index 000000000..f88e206bb --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/LICENSE.md @@ -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. \ No newline at end of file diff --git a/plugins/importer-bruno/tests/fixtures/upstream/README.md b/plugins/importer-bruno/tests/fixtures/upstream/README.md new file mode 100644 index 000000000..ed9163325 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/README.md @@ -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. diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/folder.bru b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/folder.bru new file mode 100644 index 000000000..a12392eac --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/folder.bru @@ -0,0 +1,7 @@ +meta { + name: api +} + +headers { + X-Folder-Header: api-folder +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/get-users.bru b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/get-users.bru new file mode 100644 index 000000000..be7648ce7 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/api/get-users.bru @@ -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 +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/bruno.json b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/bruno.json new file mode 100644 index 000000000..51ea9994a --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/bruno.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "name": "migration-test", + "type": "collection", + "collectionVersion": "v9.9.9", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/collection.bru b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/collection.bru new file mode 100644 index 000000000..354bf7920 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/collection.bru @@ -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. +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Local.bru b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Local.bru new file mode 100644 index 000000000..130c2b5dd --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + host: http://localhost:8081 +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Production.bru b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Production.bru new file mode 100644 index 000000000..0dce48225 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/environments/Production.bru @@ -0,0 +1,3 @@ +vars { + host: https://api.example.com +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/ping.bru b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/ping.bru new file mode 100644 index 000000000..f8227812a --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/ping.bru @@ -0,0 +1,15 @@ +meta { + name: ping + type: http + seq: 1 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +assert { + res.status: eq 200 +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/post-json.bru b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/post-json.bru new file mode 100644 index 000000000..5f877a7d6 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/bru-migration/post-json.bru @@ -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 +} diff --git a/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/folder.yml b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/folder.yml new file mode 100644 index 000000000..9a2f65edf --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/folder.yml @@ -0,0 +1,4 @@ +info: + name: folder_1 + type: folder + seq: 1 diff --git a/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/request_1.yml b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/request_1.yml new file mode 100644 index 000000000..4aa2701a4 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_1/request_1.yml @@ -0,0 +1,8 @@ +info: + name: request_1 + type: http + seq: 1 + +http: + method: GET + url: https://api.example.com/folder-1/one diff --git a/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/folder.yml b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/folder.yml new file mode 100644 index 000000000..41ca84bae --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/folder.yml @@ -0,0 +1,4 @@ +info: + name: folder_2 + type: folder + seq: 2 diff --git a/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/request_1.yml b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/request_1.yml new file mode 100644 index 000000000..1f40f3807 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/folder_2/request_1.yml @@ -0,0 +1,8 @@ +info: + name: request_1 + type: http + seq: 1 + +http: + method: GET + url: https://api.example.com/folder-2/one diff --git a/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/opencollection.yml b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/opencollection.yml new file mode 100644 index 000000000..abea672ea --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/opencollection.yml @@ -0,0 +1,4 @@ +opencollection: "1.0.0" +info: + name: ymlcollection + version: "3.4.0" diff --git a/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_1.yml b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_1.yml new file mode 100644 index 000000000..797bb0654 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_1.yml @@ -0,0 +1,8 @@ +info: + name: request_1 + type: http + seq: 1 + +http: + method: GET + url: https://api.example.com/one diff --git a/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_2.yml b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_2.yml new file mode 100644 index 000000000..2578dc284 --- /dev/null +++ b/plugins/importer-bruno/tests/fixtures/upstream/yaml-docs/request_2.yml @@ -0,0 +1,8 @@ +info: + name: request_2 + type: http + seq: 2 + +http: + method: GET + url: https://api.example.com/two diff --git a/plugins/importer-bruno/tests/index.test.ts b/plugins/importer-bruno/tests/index.test.ts new file mode 100644 index 000000000..5d8355111 --- /dev/null +++ b/plugins/importer-bruno/tests/index.test.ts @@ -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 = {}) { + 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"); + }); +}); diff --git a/plugins/importer-bruno/tests/upstream.test.ts b/plugins/importer-bruno/tests/upstream.test.ts new file mode 100644 index 000000000..3ac327397 --- /dev/null +++ b/plugins/importer-bruno/tests/upstream.test.ts @@ -0,0 +1,108 @@ +import { fileURLToPath } from "node:url"; +import type { Context } from "@yaakapp/api"; +import { expect, test } from "vite-plus/test"; +import { runImporter } from "../../../packages/plugin-runtime/src/importFiles"; +import { plugin } from "../src"; + +// Inputs come unchanged from Bruno, not from this importer's model of its formats. +// See fixtures/upstream/README.md for the pinned revision and original paths. +async function importFixture(name: string) { + const path = fileURLToPath(new URL(`./fixtures/upstream/${name}`, import.meta.url)); + const result = await runImporter(plugin.importer!, {} as Context, { + content: "", + source: { type: "directory", path }, + }); + expect(result).not.toBeNull(); + return result!.resources; +} + +test("imports Bruno's .bru migration collection with scoped headers, environments, and JSON body", async () => { + const resources = await importFixture("bru-migration"); + const [workspace] = resources.workspaces; + expect(resources.workspaces).toHaveLength(1); + expect(workspace).toMatchObject({ + name: "migration-test", + headers: [{ name: "X-Collection-Header", value: "migration-test", enabled: true }], + }); + expect(workspace?.description).toContain("Migration Test Collection"); + expect(workspace?.description).toContain("collectionVar"); + expect(resources.environments).toHaveLength(3); + expect(resources.environments.find((e) => e.name === "Local")?.variables).toEqual([ + { name: "host", value: "http://localhost:8081", enabled: true }, + ]); + expect(resources.environments.find((e) => e.name === "Production")?.variables).toEqual([ + { name: "host", value: "https://api.example.com", enabled: true }, + ]); + expect(resources.folders).toHaveLength(1); + const [folder] = resources.folders; + expect(folder).toMatchObject({ + name: "api", + folderId: null, + workspaceId: workspace!.id, + headers: [{ name: "X-Folder-Header", value: "api-folder", enabled: true }], + }); + expect(resources.httpRequests).toHaveLength(3); + const getUsers = resources.httpRequests.find((r) => r.name === "get-users"); + expect(getUsers).toMatchObject({ + folderId: folder!.id, + method: "GET", + url: "${[ host ]}/api/echo/json", + authenticationType: "none", + headers: [{ name: "Accept", value: "application/json", enabled: true }], + }); + expect(getUsers?.description).toContain("requestVar"); + expect(getUsers?.description).toContain("res.status"); + expect(resources.httpRequests.find((r) => r.name === "ping")).toMatchObject({ + folderId: null, + url: "${[ host ]}/ping", + method: "GET", + sortPriority: 1, + }); + const post = resources.httpRequests.find((r) => r.name === "post-json"); + expect(post).toMatchObject({ + folderId: null, + method: "POST", + url: "${[ host ]}/api/echo/json", + sortPriority: 2, + bodyType: "application/json", + headers: [{ name: "Content-Type", value: "application/json", enabled: true }], + }); + expect(JSON.parse(post!.body!.text as string)).toEqual({ + message: "hello from migration test", + }); +}); + +test("imports Bruno's YAML docs collection without conflating repeated request names", async () => { + const resources = await importFixture("yaml-docs"); + expect(resources.workspaces).toHaveLength(1); + expect(resources.workspaces[0]?.name).toBe("ymlcollection"); + expect(resources.folders).toHaveLength(2); + expect(resources.httpRequests).toHaveLength(4); + for (const n of [1, 2]) { + const folder = resources.folders.find((f) => f.name === `folder_${n}`); + expect(folder).toMatchObject({ folderId: null, sortPriority: n }); + expect(resources.httpRequests.filter((r) => r.folderId === folder!.id)).toEqual([ + expect.objectContaining({ + name: "request_1", + method: "GET", + url: `https://api.example.com/folder-${n}/one`, + sortPriority: 1, + }), + ]); + } + expect(resources.httpRequests.filter((r) => r.folderId === null)).toEqual([ + expect.objectContaining({ + name: "request_1", + method: "GET", + url: "https://api.example.com/one", + sortPriority: 1, + }), + expect.objectContaining({ + name: "request_2", + method: "GET", + url: "https://api.example.com/two", + sortPriority: 2, + }), + ]); + expect(new Set(resources.httpRequests.map((r) => r.id)).size).toBe(4); +}); diff --git a/plugins/importer-bruno/tsconfig.json b/plugins/importer-bruno/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/plugins/importer-bruno/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts index 72f4b92ad..8e7b27c03 100644 --- a/plugins/importer-curl/src/index.ts +++ b/plugins/importer-curl/src/index.ts @@ -4,6 +4,7 @@ import type { Folder, HttpRequest, HttpUrlParameter, + ImportSource, PluginDefinition, Workspace, } from "@yaakapp/api"; @@ -86,9 +87,10 @@ export const plugin: PluginDefinition = { importer: { name: "cURL", description: "Import cURL commands", - onImport(_ctx: Context, args: { text: string }) { + onImportSource(_ctx: Context, { source }: { source: ImportSource }) { + if (source.type !== "text") return null; // oxlint-disable-next-line no-explicit-any - return convertCurl(args.text) as any; + return convertCurl(source.text) as any; }, }, }; diff --git a/plugins/importer-insomnia/src/index.ts b/plugins/importer-insomnia/src/index.ts index cd54fc770..104227866 100644 --- a/plugins/importer-insomnia/src/index.ts +++ b/plugins/importer-insomnia/src/index.ts @@ -1,4 +1,4 @@ -import type { Context, PluginDefinition } from "@yaakapp/api"; +import type { Context, ImportSource, PluginDefinition } from "@yaakapp/api"; import YAML from "yaml"; import { deleteUndefinedAttrs, isJSObject } from "./common"; import { convertInsomniaV4 } from "./v4"; @@ -8,8 +8,9 @@ export const plugin: PluginDefinition = { importer: { name: "Insomnia", description: "Import Insomnia workspaces", - async onImport(_ctx: Context, args: { text: string }) { - return convertInsomnia(args.text); + async onImportSource(_ctx: Context, { source }: { source: ImportSource }) { + if (source.type !== "text") return null; + return convertInsomnia(source.text); }, }, }; diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 302c04b7c..cb419f77c 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -5,6 +5,7 @@ import type { HttpRequest, HttpRequestHeader, HttpUrlParameter, + ImportSource, PartialImportResources, PluginDefinition, Workspace, @@ -46,8 +47,9 @@ export const plugin: PluginDefinition = { importer: { name: "OpenAPI", description: "Import OpenAPI collections", - onImport(_ctx: Context, args: { text: string }) { - return convertOpenApi(args.text); + onImportSource(_ctx: Context, { source }: { source: ImportSource }) { + if (source.type !== "text") return null; + return convertOpenApi(source.text); }, }, }; diff --git a/plugins/importer-postman-environment/src/index.ts b/plugins/importer-postman-environment/src/index.ts index 543339430..276539d30 100644 --- a/plugins/importer-postman-environment/src/index.ts +++ b/plugins/importer-postman-environment/src/index.ts @@ -2,6 +2,7 @@ import type { Context, Environment, + ImportSource, PartialImportResources, PluginDefinition, Workspace, @@ -19,8 +20,9 @@ export const plugin: PluginDefinition = { importer: { name: "Postman Environment", description: "Import postman environment exports", - onImport(_ctx: Context, args: { text: string }) { - return convertPostmanEnvironment(args.text); + onImportSource(_ctx: Context, { source }: { source: ImportSource }) { + if (source.type !== "text") return null; + return convertPostmanEnvironment(source.text); }, }, }; diff --git a/plugins/importer-postman/src/index.ts b/plugins/importer-postman/src/index.ts index 248241d46..601229eef 100644 --- a/plugins/importer-postman/src/index.ts +++ b/plugins/importer-postman/src/index.ts @@ -6,6 +6,7 @@ import type { HttpRequest, HttpRequestHeader, HttpUrlParameter, + ImportSource, PartialImportResources, PluginDefinition, Workspace, @@ -56,8 +57,9 @@ export const plugin: PluginDefinition = { importer: { name: "Postman", description: "Import postman collections", - onImport(_ctx: Context, args: { text: string }) { - return convertPostman(args.text); + onImportSource(_ctx: Context, { source }: { source: ImportSource }) { + if (source.type !== "text") return null; + return convertPostman(source.text); }, }, }; diff --git a/plugins/importer-yaak/src/index.ts b/plugins/importer-yaak/src/index.ts index f423b9e52..2bb6894d7 100644 --- a/plugins/importer-yaak/src/index.ts +++ b/plugins/importer-yaak/src/index.ts @@ -4,8 +4,9 @@ export const plugin: PluginDefinition = { importer: { name: "Yaak", description: "Yaak official format", - onImport(_ctx, args) { - return migrateImport(args.text); + onImportSource(_ctx, { source }) { + if (source.type !== "text") return null; + return migrateImport(source.text); }, }, }; diff --git a/vite.config.ts b/vite.config.ts index 52b943da1..d009150e4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,14 +4,15 @@ export default defineConfig({ staged: { "*": "vp check --fix", }, - // Generated output, reformatted only to be undone by the next regen. Read by every formatter - // entry point, including the `staged` task above. + // Preserve generated output and upstream fixtures. Read by every formatter entry point, + // including the `staged` task above. fmt: { ignorePatterns: [ "**/bindings/**", "**/routeTree.gen.ts", "crates/yaak-templates/pkg/**", "crates/yaak-wasm/pkg/**", + "plugins/importer-bruno/tests/fixtures/upstream/**", ], }, lint: {