Compare commits

..
Author SHA1 Message Date
Gregory Schier 4b2dcf9a1a Open Settings in a dialog on the web build (#581) 2026-08-17 19:07:25 -07:00
Gregory Schier 569f552d79 Stop sending scope on the authorization code token request (#579) 2026-08-17 12:23:08 -07:00
Gregory Schier 33f32cccf6 Leave browser zoom to the browser (#578)
The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site.

Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure.
2026-08-17 11:29:20 -07:00
Gregory Schier 7ca772347f Sweep orphaned response bodies at browser startup (#576) 2026-08-17 10:38:52 -07:00
Gregory Schier b89c448345 Fix cookie defaults and Timeline ordering, and give models a real Default (#573) 2026-08-17 09:14:17 -07:00
Gregory Schier 2021df112a Implement response deletes in the browser host instead of declining them (#574) 2026-08-17 09:02:18 -07:00
55 changed files with 1009 additions and 1391 deletions
Generated
+12 -1
View File
@@ -11199,7 +11199,6 @@ dependencies = [
"base64 0.22.1",
"log 0.4.29",
"md5 0.8.0",
"rusqlite",
"serde_json",
"tempfile",
"thiserror 2.0.17",
@@ -11278,6 +11277,7 @@ dependencies = [
"yaak-grpc",
"yaak-http",
"yaak-license",
"yaak-lifecycle",
"yaak-mac-window",
"yaak-models",
"yaak-plugins",
@@ -11343,6 +11343,7 @@ dependencies = [
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-lifecycle",
"yaak-models",
"yaak-plugins",
"yaak-templates",
@@ -11527,6 +11528,14 @@ dependencies = [
"yaak-models",
]
[[package]]
name = "yaak-lifecycle"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"yaak-models",
]
[[package]]
name = "yaak-mac-window"
version = "0.1.0"
@@ -11746,6 +11755,8 @@ dependencies = [
"sqlite-wasm-vfs",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yaak-lifecycle",
"yaak-models",
]
+2
View File
@@ -14,6 +14,7 @@ members = [
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -77,6 +78,7 @@ yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
+22 -13
View File
@@ -1,33 +1,42 @@
import { platform } from "@yaakapp-internal/platform";
import type { SettingsTab } from "../components/Settings/Settings";
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { rpc } from "../lib/rpc";
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
mutationKey: ["open_settings"],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
const to = "/workspaces/$workspaceId/settings" as const;
const params = { workspaceId };
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
// Settings is its own window where the host has windows to give. Where it
// doesn't — a browser tab — the same route opens in place, which is the
// whole difference: it is already a route, not a separate app.
// doesn't — a browser tab — it's a dialog like any other, so opening it
// doesn't take you away from the request you were working on.
if (!platform.capabilities.multiWindow) {
await router.navigate({ to, params, search });
// Imported here so Settings stays out of the startup bundle, the way the
// route that renders it on desktop already keeps it
const { default: Settings } = await import("../components/Settings/Settings");
showDialog({
id: "settings",
size: "md",
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
noPadding: true,
noScroll: true,
// Keyed so opening a specific tab while the dialog is already up moves to it
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
});
return;
}
const location = router.buildLocation({ to, params, search });
const location = router.buildLocation({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
await rpc("cmd_new_child_window", {
url: location.href,
+21 -186
View File
@@ -1,34 +1,17 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
modelTypeLabel,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { pluralizeCount } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { RadioCards } from "./core/RadioCards";
interface Props {
currentWorkspace: Workspace | null;
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
}
type DestinationChoice = "new_workspace" | "current_workspace";
/**
* 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).
@@ -48,21 +31,8 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({
currentWorkspace,
selectedFolder,
planFile,
planUrl,
commit,
cancel,
onError,
}: Props) {
export function ImportDataDialog({ importFile, importUrl }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [destinationChoice, setDestinationChoice] = useState<DestinationChoice>(
currentWorkspace == null ? "new_workspace" : "current_workspace",
);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
@@ -101,110 +71,19 @@ export function ImportDataDialog({
selectSource(selected);
};
const destination = (): ImportDestination => {
if (destinationChoice === "current_workspace" && currentWorkspace != null) {
return {
type: "current_workspace",
workspaceId: currentWorkspace.id,
folderId: targetSelectedFolder ? selectedFolder?.id : undefined,
};
}
return { type: "new_workspace" };
};
const handlePreview = async () => {
const handleImport = async () => {
setIsLoading(true);
try {
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
} catch (err) {
onError(err);
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit(plan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
if (plan != null) {
const counts = [
[plan.resources.workspaces[0]?.resource, plan.resources.workspaces.length],
[plan.resources.environments[0]?.resource, plan.resources.environments.length],
[plan.resources.folders[0]?.resource, plan.resources.folders.length],
[plan.resources.httpRequests[0]?.resource, plan.resources.httpRequests.length],
[plan.resources.grpcRequests[0]?.resource, plan.resources.grpcRequests.length],
[plan.resources.websocketRequests[0]?.resource, plan.resources.websocketRequests.length],
] as const;
const destinationLabel =
plan.destination.type === "new_workspace"
? "New workspace"
: selectedFolder != null && plan.destination.folderId === selectedFolder.id
? `${currentWorkspace?.name ?? "Current workspace"} / ${selectedFolder.name}`
: (currentWorkspace?.name ?? "Current workspace");
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div>
<div className="text-sm font-semibold mb-1">Resources</div>
<ul className="list-disc pl-6 text-sm text-text-subtle">
{counts.map(([model, count]) =>
model == null ? null : (
<li key={model.model}>{pluralizeCount(modelTypeLabel(model), count)}</li>
),
)}
</ul>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading ? "Importing" : "Confirm Import"}
</Button>
</HStack>
</VStack>
);
}
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -236,69 +115,25 @@ export function ImportDataDialog({
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<div className="text-sm font-semibold">Import destination</div>
<RadioCards
name="import-destination"
value={destinationChoice}
onChange={setDestinationChoice}
options={[
{
value: "new_workspace",
label: "New workspace",
description: "Create imported resources in a separate workspace.",
},
...(currentWorkspace == null
? []
: [
{
value: "current_workspace" as const,
label: currentWorkspace.name,
description: "Add resources without changing this workspace's settings.",
},
]),
]}
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
{destinationChoice === "current_workspace" && selectedFolder != null && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
onClick={handlePreview}
size="sm"
onClick={handleImport}
>
{isLoading ? "Analyzing" : "Preview Import"}
{isLoading ? "Importing" : "Import"}
</Button>
</HStack>
</VStack>
</VStack>
);
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
@@ -1,4 +1,3 @@
import { useSearch } from "@tanstack/react-router";
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
@@ -20,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
import { SettingsTheme } from "./SettingsTheme";
interface Props {
tab?: SettingsTabWithSubtab | null;
/** Set when Settings is in a dialog rather than owning a window. */
hide?: () => void;
}
@@ -42,25 +43,19 @@ const tabs = [
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
export default function Settings({ tab, hide }: Props) {
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
const [mainTab, subtab] = tab?.split(":") ?? [];
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
useKeyPressEvent("Escape", async () => {
if (hide != null) {
// It's being shown in a dialog, so close the dialog
hide();
} else {
// It's being shown in a window, so close the window
await platform.window.close();
}
if (hide == null) await platform.window.close();
});
return (
@@ -90,7 +85,7 @@ export default function Settings({ hide }: Props) {
)}
<Tabs
layout="horizontal"
defaultValue={mainTab || tabFromQuery}
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action]}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
+5 -7
View File
@@ -10,13 +10,11 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
disableBackdropClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -29,7 +27,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableClose,
disableBackdropClose,
title,
description,
hideX,
@@ -44,7 +42,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -60,7 +58,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
if (!disableClose) onClose?.();
onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -112,7 +110,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
{!hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
+3 -1
View File
@@ -86,8 +86,10 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableClose: true,
disableBackdropClose: true,
onClose: () => resolve("cancel"),
render: ({ hide }) =>
DivergedDialog({
remote,
+23 -11
View File
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
// Merge default hotkeys with custom hotkeys from settings
// Custom hotkeys override defaults for the same action
// An empty array means the hotkey is intentionally disabled
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
const merged: Partial<Record<HotkeyAction, string[]>> = {};
for (const action of hotkeyActions) {
merged[action] = defaultHotkeys[action];
}
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in defaultHotkeys && Array.isArray(keys)) {
if (action in merged && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Record<HotkeyAction, string[]> {
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
return jotaiStore.get(hotkeysAtom);
}
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
"Space",
];
/** Zoom is the browser's own on these keys, so the app has no such action there. */
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
/**
* The actions this host actually has. An action left out of here has no keys in
* `hotkeysAtom`, so it never matches and never claims the keystroke.
*/
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
).sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
)
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
+2 -1
View File
@@ -14,8 +14,9 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({
id,
title,
hideX: true,
size,
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
+2 -1
View File
@@ -18,8 +18,9 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({
...extraProps,
hideX: true,
size,
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});
});
+14 -29
View File
@@ -1,13 +1,10 @@
import type { BatchUpsertResult, ImportDestination, ImportPlan } from "@yaakapp-internal/models";
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
@@ -24,41 +21,29 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "sm",
disableClose: true,
render: ({ hide }) => {
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
};
return (
<ImportDataDialog
currentWorkspace={currentWorkspace}
selectedFolder={selectedFolder}
planFile={(filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
}
planUrl={(url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination })
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
}
commit={commit}
cancel={cancel}
onError={fail}
/>
);
},
+6 -1
View File
@@ -25,8 +25,13 @@ export async function showPromptForm({
id,
title,
description,
hideX: true,
size: size ?? "sm",
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
render: ({ hide }) =>
Prompt({
onCancel: () => {
@@ -14,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
});
function RouteComponent() {
return <Settings />;
const { tab } = Route.useSearch();
return <Settings tab={tab} />;
}
+1
View File
@@ -45,6 +45,7 @@ yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
@@ -5,7 +5,8 @@ use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_models::util::{BatchUpsertResult, ImportDestination};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -50,7 +51,6 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -59,13 +59,13 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(),
);
}
let destination = match workspace_id {
Some(workspace_id) => ImportDestination::CurrentWorkspace { workspace_id, folder_id: None },
None => ImportDestination::NewWorkspace,
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
};
let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let imported = import::commit_import_plan(ctx.query_manager(), plan)
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
+6 -9
View File
@@ -435,15 +435,12 @@ fn create(
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
let name = name.unwrap_or_default();
let url = url.unwrap_or_default();
let method = method.unwrap_or_else(|| "GET".to_string());
let request = HttpRequest {
workspace_id,
name,
method: method.to_uppercase(),
url,
..Default::default()
};
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
// Only override the method when one was given; `HttpRequest::default()` is the
// single place the fallback ("GET") is defined.
if let Some(method) = method {
request.method = method.to_uppercase();
}
let created = ctx
.db()
+8
View File
@@ -49,6 +49,14 @@ impl CliContext {
std::process::exit(1);
}
};
// Guest: the desktop may have this DB open, so only what's safe beside a live session
let _ = yaak_lifecycle::on_launch(
&yaak_lifecycle::Host::guest(),
&query_manager.connect(),
&blob_manager,
);
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
Self {
@@ -81,21 +81,14 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
.find(|workspace| workspace.name == "Imported Workspace")
.expect("workspace imported");
assert_ne!(workspace.id, "wrk_import");
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
}
fn write_postman_environment_fixture(path: &std::path::Path) {
+1
View File
@@ -88,6 +88,7 @@ yaak-grpc = { workspace = true }
yaak-http = { workspace = true }
yaak-license = { workspace = true, optional = true }
yaak-mac-window = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
+18 -28
View File
@@ -4,63 +4,53 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, PlanImportDataParams};
use yaak::import::{self, ImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
) -> Result<BatchUpsertResult> {
let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?;
commit_import(window, plan)
}
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination).await
import_contents(window, &contents).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
pub(crate) async fn import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
) -> Result<BatchUpsertResult> {
let contents = fetch_import_url(window, url).await?;
plan_import_contents(window, &contents, destination).await
import_contents(window, &contents).await
}
async fn plan_import_contents<R: Runtime>(
async fn import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::plan_import_data(PlanImportDataParams {
Ok(import::import_data(ImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
destination,
workspace_context,
contents,
})
.await?)
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
+13 -23
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::import::{import_data, import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -40,7 +40,7 @@ use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
@@ -1013,24 +1013,15 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
) -> YaakResult<BatchUpsertResult> {
import_data(&window, file_path).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
}
async fn cmd_commit_import<R: Runtime>(
window: WebviewWindow<R>,
plan: ImportPlan,
) -> YaakResult<BatchUpsertResult> {
commit_import(&window, plan)
import_url(&window, url).await
}
@@ -1267,6 +1258,14 @@ pub fn run() {
builder
.setup(|app| {
let lifecycle_host = yaak_lifecycle::Host::owner()
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
if let Err(e) =
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
{
error!("on_launch hook failed: {e:?}");
}
// The RPC command registry — every frontend command dispatches
// through this via the single `rpc` Tauri command
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
@@ -1366,15 +1365,6 @@ pub fn run() {
let info = history::get_or_upsert_launch_info(&h);
debug!("Launched Yaak {:?}", info);
});
// Cancel pending requests
let h = app_handle.clone();
tauri::async_runtime::block_on(async move {
let db = h.db();
let _ = db.cancel_pending_http_responses();
let _ = db.cancel_pending_grpc_connections();
let _ = db.cancel_pending_websocket_connections();
});
}
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -15,7 +15,6 @@ use yaak_models::error::Result;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
@@ -152,30 +151,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
};
let db = query_manager.connect();
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
{
error!("Failed to prune model_changes rows on startup: {err:?}");
}
// Only stream writes that happen after this app launch.
let cursor = ModelChangeCursor::from_launch_time();
let poll_query_manager = query_manager.clone();
// GC response bodies orphaned by cascade deletes, which historically
// didn't clean the blob DB or responses directory
let gc_query_manager = query_manager.clone();
let gc_blob_manager = blob_manager.clone();
let gc_responses_dir = app_path.join("responses");
tauri::async_runtime::spawn_blocking(move || {
let db = gc_query_manager.connect();
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
Ok(0) => {}
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
}
});
app_handle.manage(query_manager);
app_handle.manage(blob_manager);
+6 -9
View File
@@ -40,7 +40,7 @@ use yaak_models::models::{
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
@@ -441,16 +441,12 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
@@ -847,3 +843,4 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
}
File diff suppressed because one or more lines are too long
-10
View File
@@ -2,13 +2,3 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
+3 -13
View File
@@ -23,7 +23,7 @@ use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -229,7 +229,6 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
@@ -237,14 +236,6 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
@@ -918,9 +909,8 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "yaak-lifecycle"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
log = { workspace = true }
yaak-models = { workspace = true }
+130
View File
@@ -0,0 +1,130 @@
//! Lifecycle hooks shared by every host (desktop, browser, CLI). The hooks say
//! what happens at each moment; the host decides when and on which thread.
//!
//! Builds for wasm32, so it can depend on `yaak-models` but not on the send
//! engine or plugin runtime.
use log::info;
use std::path::PathBuf;
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::error::Result;
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// Has the database for the life of the app (desktop, browser worker)
Owner,
/// Short-lived, and an owner may be using the database right now (CLI).
/// Must not touch anything in flight.
Guest,
}
/// Paths are `None` on hosts without a filesystem (the browser).
#[derive(Debug, Clone)]
pub struct Host {
pub role: Role,
pub responses_dir: Option<PathBuf>,
}
impl Host {
pub fn owner() -> Self {
Self { role: Role::Owner, responses_dir: None }
}
pub fn guest() -> Self {
Self { role: Role::Guest, responses_dir: None }
}
pub fn with_responses_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.responses_dir = Some(dir.into());
self
}
}
/// Run once after the database is open, before the host answers anything.
pub fn on_launch(host: &Host, db: &ClientDb, blobs: &BlobManager) -> Result<()> {
db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)?;
if host.role == Role::Owner {
// Anything still in flight was left by the last session
db.cancel_pending_http_responses()?;
db.cancel_pending_grpc_connections()?;
db.cancel_pending_websocket_connections()?;
// Cascaded deletes never cleaned up response bodies
let deleted = match host.responses_dir.as_deref() {
Some(dir) => db.delete_orphaned_response_bodies(blobs, dir)?,
None => db.delete_orphaned_response_body_blobs(blobs)?,
};
if deleted > 0 {
info!("Deleted {deleted} orphaned response bodies");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use yaak_models::blob_manager::BodyChunk;
use yaak_models::init_in_memory;
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseState, Workspace};
use yaak_models::util::UpdateSource;
#[test]
fn only_the_owner_closes_what_the_last_session_left_open() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let source = &UpdateSource::Background;
let workspace = db
.upsert_workspace(
&Workspace { name: "Hooks".to_string(), ..Default::default() },
source,
)
.unwrap();
let request = db
.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)
.unwrap();
let pending = db
.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
state: HttpResponseState::Connected,
..Default::default()
},
source,
&blob_manager,
)
.unwrap();
on_launch(&Host::guest(), &db, &blob_manager).unwrap();
let response = db.get_http_response(&pending.id).unwrap();
assert!(matches!(response.state, HttpResponseState::Connected));
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
let response = db.get_http_response(&pending.id).unwrap();
assert!(matches!(response.state, HttpResponseState::Closed));
}
#[test]
fn owner_without_a_filesystem_still_sweeps_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
{
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
}
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
}
}
-10
View File
@@ -2,13 +2,3 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
+59
View File
@@ -0,0 +1,59 @@
import { createStore } from "jotai";
import { expect, test } from "vitest";
import type { HttpResponseEvent } from "../bindings/gen_models";
import { httpResponseEventsAtom, modelStoreDataAtom } from "./atoms";
import { newStoreData } from "./util";
// The five setting events that every send writes, all within the same millisecond
const SETTING_NAMES = [
"validate_certificates",
"redirects",
"timeout",
"send_cookies",
"store_cookies",
];
function settingEvent(id: string, name: string, createdAt: string): HttpResponseEvent {
return {
model: "http_response_event",
id,
createdAt,
updatedAt: createdAt,
workspaceId: "wk_1",
responseId: "rs_1",
event: { type: "setting", name, value: "true" },
};
}
test("events with equal createdAt keep store (DB) insertion order", () => {
const store = createStore();
const data = newStoreData();
SETTING_NAMES.forEach((name, i) => {
data.http_response_event[`hre_${i}`] = settingEvent(
`hre_${i}`,
name,
"2026-08-17T00:00:00.123",
);
});
store.set(modelStoreDataAtom, data);
const names = store.get(httpResponseEventsAtom).map((e) => {
return e.event.type === "setting" ? e.event.name : e.event.type;
});
expect(names).toEqual(SETTING_NAMES);
});
test("events with distinct createdAt sort ascending", () => {
const store = createStore();
const data = newStoreData();
for (const [id, createdAt] of [
["hre_b", "2026-08-17T00:00:00.456"],
["hre_a", "2026-08-17T00:00:00.123"],
["hre_c", "2026-08-17T00:00:00.789"],
]) {
data.http_response_event[id!] = settingEvent(id!, "timeout", createdAt!);
}
store.set(modelStoreDataAtom, data);
expect(store.get(httpResponseEventsAtom).map((e) => e.id)).toEqual(["hre_a", "hre_b", "hre_c"]);
});
+3 -1
View File
@@ -61,7 +61,9 @@ export function createOrderedModelAtom<M extends AnyModel["model"]>(
const modelData = data[modelType] ?? {};
return Object.values(modelData).sort(
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
const n = a[field] > b[field] ? 1 : -1;
// NOTE: ties must return 0, or the comparator is inconsistent and V8 reorders
// equal-keyed rows. Sort is stable, so 0 preserves store (DB) insertion order.
const n = a[field] === b[field] ? 0 : a[field] > b[field] ? 1 : -1;
return order === "desc" ? n * -1 : n;
},
);
+252 -39
View File
@@ -60,8 +60,22 @@ pub struct ProxySettingAuth {
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
impl Default for ClientCertificate {
fn default() -> Self {
Self {
host: String::new(),
port: None,
crt_file: None,
key_file: None,
pfx_file: None,
passphrase: None,
enabled: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ClientCertificate {
pub host: String,
@@ -75,13 +89,18 @@ pub struct ClientCertificate {
pub pfx_file: Option<String>,
#[serde(default)]
pub passphrase: Option<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
impl Default for DnsOverride {
fn default() -> Self {
Self { hostname: String::new(), ipv4: Vec::new(), ipv6: Vec::new(), enabled: true }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct DnsOverride {
pub hostname: String,
@@ -89,7 +108,6 @@ pub struct DnsOverride {
pub ipv4: Vec<String>,
#[serde(default)]
pub ipv6: Vec<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
@@ -147,7 +165,6 @@ pub struct InheritedBoolSetting {
#[serde(default)]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
#[serde(default = "default_true")]
pub value: bool,
}
@@ -383,7 +400,31 @@ impl UpsertModelInfo for Settings {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for Workspace {
fn default() -> Self {
Self {
model: "workspace".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
authentication: BTreeMap::new(),
authentication_type: None,
description: String::new(),
headers: Vec::new(),
name: String::new(),
encryption_key_challenge: None,
setting_validate_certificates: true,
setting_follow_redirects: true,
setting_request_timeout: 0,
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
setting_dns_overrides: Vec::new(),
setting_send_cookies: true,
setting_store_cookies: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "workspaces")]
@@ -403,18 +444,13 @@ pub struct Workspace {
pub encryption_key_challenge: Option<String>,
// Settings
#[serde(default = "default_true")]
pub setting_validate_certificates: bool,
#[serde(default = "default_true")]
pub setting_follow_redirects: bool,
pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32,
#[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")]
pub setting_send_cookies: bool,
#[serde(default = "default_true")]
pub setting_store_cookies: bool,
}
@@ -920,11 +956,16 @@ impl UpsertModelInfo for Environment {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for EnvironmentVariable {
fn default() -> Self {
Self { enabled: true, name: String::new(), value: String::new(), id: None }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct EnvironmentVariable {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
pub name: String,
@@ -949,7 +990,35 @@ pub struct ParentHeaders {
pub headers: Vec<HttpRequestHeader>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for Folder {
fn default() -> Self {
Self {
model: "folder".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication: BTreeMap::new(),
authentication_type: None,
description: String::new(),
headers: Vec::new(),
name: String::new(),
sort_priority: 0.0,
setting_send_cookies: InheritedBoolSetting::default(),
setting_store_cookies: InheritedBoolSetting::default(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_follow_redirects: InheritedBoolSetting::default(),
setting_request_timeout: InheritedIntSetting::default(),
setting_request_message_size: InheritedIntSetting {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "folders")]
@@ -974,7 +1043,6 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
@@ -1088,11 +1156,16 @@ impl UpsertModelInfo for Folder {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for HttpRequestHeader {
fn default() -> Self {
Self { enabled: true, name: String::new(), value: String::new(), id: None }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequestHeader {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
pub name: String,
@@ -1101,11 +1174,16 @@ pub struct HttpRequestHeader {
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for HttpUrlParameter {
fn default() -> Self {
Self { enabled: true, name: String::new(), value: String::new(), id: None }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpUrlParameter {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
/// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
@@ -1116,7 +1194,36 @@ pub struct HttpUrlParameter {
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for HttpRequest {
fn default() -> Self {
Self {
model: "http_request".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication: BTreeMap::new(),
authentication_type: None,
body: BTreeMap::new(),
body_type: None,
description: String::new(),
headers: Vec::new(),
method: "GET".to_string(),
name: String::new(),
sort_priority: 0.0,
url: String::new(),
url_parameters: Vec::new(),
setting_send_cookies: InheritedBoolSetting::default(),
setting_store_cookies: InheritedBoolSetting::default(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_follow_redirects: InheritedBoolSetting::default(),
setting_request_timeout: InheritedIntSetting::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "http_requests")]
@@ -1137,7 +1244,6 @@ pub struct HttpRequest {
pub body_type: Option<String>,
pub description: String,
pub headers: Vec<HttpRequestHeader>,
#[serde(default = "default_http_method")]
pub method: String,
pub name: String,
pub sort_priority: f64,
@@ -1393,7 +1499,36 @@ impl Default for WebsocketMessageType {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for WebsocketRequest {
fn default() -> Self {
Self {
model: "websocket_request".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication: BTreeMap::new(),
authentication_type: None,
description: String::new(),
headers: Vec::new(),
message: String::new(),
name: String::new(),
sort_priority: 0.0,
url: String::new(),
url_parameters: Vec::new(),
setting_send_cookies: InheritedBoolSetting::default(),
setting_store_cookies: InheritedBoolSetting::default(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_request_message_size: InheritedIntSetting {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "websocket_requests")]
@@ -1420,7 +1555,6 @@ pub struct WebsocketRequest {
pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
@@ -2053,7 +2187,35 @@ impl UpsertModelInfo for GraphQlIntrospection {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
impl Default for GrpcRequest {
fn default() -> Self {
Self {
model: "grpc_request".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication_type: None,
authentication: BTreeMap::new(),
description: String::new(),
message: String::new(),
metadata: Vec::new(),
method: None,
name: String::new(),
service: None,
sort_priority: 0.0,
url: String::new(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_request_message_size: InheritedIntSetting {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "grpc_requests")]
@@ -2079,7 +2241,6 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure)
pub url: String,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
@@ -2730,22 +2891,12 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
}
}
fn default_true() -> bool {
true
}
fn default_request_message_size() -> i32 {
DEFAULT_REQUEST_MESSAGE_SIZE
}
/// Only used as a `from_row` fallback for an unparseable settings column. The
/// value a *new* model gets comes from that model's `Default` impl.
fn default_request_message_size_setting() -> InheritedIntSetting {
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
}
fn default_http_method() -> String {
"GET".to_string()
}
#[macro_export]
macro_rules! define_any_model {
($($type:ident),* $(,)?) => {
@@ -2889,3 +3040,65 @@ impl AnyModel {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every model below carries `#[serde(default)]` at the container level, so a
/// missing key is filled from `Default::default()`, which makes each `Default`
/// impl the single definition of that model's defaults.
///
/// Deserializing `{}` therefore equals `Default::default()` by construction
/// today. What this catches is the two ways that can come apart again, both of
/// which have already bitten us:
///
/// 1. A field-level `#[serde(default = "...")]` (or bare `#[serde(default)]`)
/// added back on a field whose `Default` says something else. That is exactly
/// the shape of the bug this replaced: `setting_send_cookies` deserialized as
/// true but a derived `Default` produced false, so the bootstrapped workspace
/// silently sent no cookies.
/// 2. The container-level `#[serde(default)]` being dropped, which turns every
/// missing key into a deserialization error instead.
macro_rules! assert_default_matches_serde {
($($t:ty),+ $(,)?) => {
$(
assert_eq!(
serde_json::from_str::<$t>("{}").expect(concat!(
stringify!($t),
" must deserialize from an empty object"
)),
<$t>::default(),
concat!(stringify!($t), ": Default::default() disagrees with its serde defaults"),
);
)+
};
}
#[test]
fn defaults_match_serde_defaults() {
assert_default_matches_serde!(
Workspace,
HttpRequest,
Folder,
GrpcRequest,
WebsocketRequest,
HttpRequestHeader,
HttpUrlParameter,
EnvironmentVariable,
DnsOverride,
ClientCertificate,
InheritedBoolSetting,
InheritedIntSetting,
);
}
#[test]
fn defaults_carry_their_model_name() {
assert_eq!(Workspace::default().model, "workspace");
assert_eq!(HttpRequest::default().model, "http_request");
assert_eq!(Folder::default().model, "folder");
assert_eq!(GrpcRequest::default().model, "grpc_request");
assert_eq!(WebsocketRequest::default().model, "websocket_request");
}
}
@@ -45,6 +45,31 @@ impl<'a> ClientDb<'a> {
Ok(count)
}
/// Delete blob-stored response bodies whose owning HTTP response row no
/// longer exists. Blob ids are keyed by the response that owns them —
/// "{response_id}" for a response body, "{response_id}.request" for the
/// request that produced it — so ownership is the id's first segment.
///
/// The blob half of [`Self::delete_orphaned_response_bodies`], on its own
/// for hosts with no filesystem to hold body files. See `crate::hooks`.
///
/// Returns the number of orphaned bodies deleted.
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
let mut deleted = 0;
let blob_ctx = blobs.connect();
for body_id in blob_ctx.list_body_ids()? {
let response_id = body_id.split('.').next().unwrap_or_default();
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
blob_ctx.delete_chunks(&body_id)?;
deleted += 1;
}
Ok(deleted)
}
/// Delete response body data (blob chunks and body files) whose owning HTTP
/// response row no longer exists. Cascaded deletes (request, folder,
/// workspace) historically never cleaned the blob DB or the responses
@@ -59,18 +84,7 @@ impl<'a> ClientDb<'a> {
blobs: &BlobManager,
responses_dir: &std::path::Path,
) -> Result<usize> {
let mut deleted = 0;
// Blob chunks are keyed "{response_id}.request"
let blob_ctx = blobs.connect();
for body_id in blob_ctx.list_body_ids()? {
let response_id = body_id.split('.').next().unwrap_or_default();
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
blob_ctx.delete_chunks(&body_id)?;
deleted += 1;
}
let mut deleted = self.delete_orphaned_response_body_blobs(blobs)?;
// Body files are stored as {responses_dir}/{response_id}
if let Ok(entries) = fs::read_dir(responses_dir) {
@@ -172,19 +186,20 @@ impl<'a> ClientDb<'a> {
#[cfg(test)]
mod tests {
use crate::blob_manager::BodyChunk;
use crate::blob_manager::{BlobManager, BodyChunk};
use crate::client_db::ClientDb;
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpResponse, Workspace};
use crate::util::UpdateSource;
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
/// A workspace, a request, and one response that still exists.
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
let source = &UpdateSource::Background;
let workspace = db
.upsert_workspace(&Workspace { name: "GC Test".to_string(), ..Default::default() }, source)
.upsert_workspace(
&Workspace { name: "GC Test".to_string(), ..Default::default() },
source,
)
.expect("Failed to upsert workspace");
let request = db
.upsert_http_request(
@@ -192,19 +207,57 @@ mod tests {
source,
)
.expect("Failed to upsert request");
db.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
blob_manager,
)
.expect("Failed to upsert response")
}
let live = db
.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
&blob_manager,
)
.expect("Failed to upsert response");
/// What a browser host runs: no filesystem, so bodies exist only as blob
/// chunks, under both id shapes the blob DB uses.
#[test]
fn deletes_orphaned_response_body_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let live = seed_live_response(&db, &blob_manager);
let live_request_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
// needs to take it
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new(&live.id, 0, b"live".to_vec())).unwrap();
blob_ctx
.insert_chunk(&BodyChunk::new(&live_request_body_id, 0, b"live".to_vec()))
.unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
}
let deleted = db
.delete_orphaned_response_body_blobs(&blob_manager)
.expect("Failed to GC response body blobs");
assert_eq!(deleted, 2);
let blob_ctx = blob_manager.connect();
assert!(blob_ctx.body_exists(&live.id).unwrap());
assert!(blob_ctx.body_exists(&live_request_body_id).unwrap());
assert!(!blob_ctx.body_exists("rs_gone").unwrap());
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
}
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let live = seed_live_response(&db, &blob_manager);
let live_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
+29 -11
View File
@@ -25,13 +25,7 @@ impl<'a> ClientDb<'a> {
if workspaces.is_empty() {
workspaces.push(self.upsert_workspace(
&Workspace {
name: "Yaak".to_string(),
setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
..Default::default()
},
&Workspace { name: "Yaak".to_string(), ..Default::default() },
&UpdateSource::Background,
)?)
}
@@ -194,16 +188,40 @@ impl<'a> ClientDb<'a> {
pub fn default_headers() -> Vec<HttpRequestHeader> {
vec![
HttpRequestHeader {
enabled: true,
name: "User-Agent".to_string(),
value: "yaak".to_string(),
id: None,
..Default::default()
},
HttpRequestHeader {
enabled: true,
name: "Accept".to_string(),
value: "*/*".to_string(),
id: None,
..Default::default()
},
]
}
#[cfg(test)]
mod tests {
use crate::init_in_memory;
#[test]
fn bootstraps_first_workspace_with_real_defaults() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
let workspace = workspaces.first().expect("No workspace was bootstrapped");
// This workspace is built in Rust and never deserialized, so it only gets
// these values if `Workspace::default()` carries them. Asserted through the
// DB round trip, since the column values are what a fresh install lives with.
assert!(workspace.setting_send_cookies, "setting_send_cookies");
assert!(workspace.setting_store_cookies, "setting_store_cookies");
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
assert!(workspace.setting_validate_certificates, "setting_validate_certificates");
assert_eq!(
workspace.setting_request_message_size,
crate::models::DEFAULT_REQUEST_MESSAGE_SIZE
);
}
}
-80
View File
@@ -85,86 +85,6 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>,
}
/// Where a staged import will be committed.
///
/// The current workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
CurrentWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
/// A model staged for import.
///
/// `source_key` is intentionally part of the plan boundary even though the first import slice does
/// not persist it. Future linked imports can populate it without changing how plans contain models.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct PlannedImportResource<T> {
#[ts(optional)]
pub source_key: Option<String>,
pub resource: T,
}
impl<T> PlannedImportResource<T> {
pub fn new(resource: T) -> Self {
Self { source_key: None, resource }
}
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanResources {
pub workspaces: Vec<PlannedImportResource<Workspace>>,
pub environments: Vec<PlannedImportResource<Environment>>,
pub folders: Vec<PlannedImportResource<Folder>>,
pub http_requests: Vec<PlannedImportResource<HttpRequest>>,
pub grpc_requests: Vec<PlannedImportResource<GrpcRequest>>,
pub websocket_requests: Vec<PlannedImportResource<WebsocketRequest>>,
}
impl ImportPlanResources {
pub fn into_batch(self) -> BatchUpsertResult {
BatchUpsertResult {
workspaces: self.workspaces.into_iter().map(|v| v.resource).collect(),
environments: self.environments.into_iter().map(|v| v.resource).collect(),
folders: self.folders.into_iter().map(|v| v.resource).collect(),
http_requests: self.http_requests.into_iter().map(|v| v.resource).collect(),
grpc_requests: self.grpc_requests.into_iter().map(|v| v.resource).collect(),
websocket_requests: self.websocket_requests.into_iter().map(|v| v.resource).collect(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: ImportPlanResources,
pub warnings: Vec<ImportPlanWarning>,
}
pub fn get_workspace_export_resources(
db: &ClientDb,
yaak_version: &str,
+1 -1
View File
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { importer: string, resources: ImportResources, };
export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
-2
View File
@@ -247,8 +247,6 @@ pub struct ImportRequest {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportResponse {
/// Display name of the importer that recognized the input.
pub importer: String,
pub resources: ImportResources,
}
+2 -13
View File
@@ -1104,19 +1104,8 @@ impl PluginManager {
.await?;
// TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
let result = reply_events.into_iter().find_map(|e| match e.payload {
InternalEventPayload::ImportResponse(resp) => Some(resp),
_ => None,
});
+2
View File
@@ -25,6 +25,7 @@ crate-type = ["cdylib", "rlib"]
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
@@ -35,3 +36,4 @@ sqlite-wasm-rs = "0.5"
sqlite-wasm-vfs = "0.2"
wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["console"] }
+10 -7
View File
@@ -677,23 +677,26 @@ export function __wbg_versions_215a3ab1c9d5745a(arg0) {
const ret = arg0.versions;
return ret;
}
export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
console.warn(arg0, arg1);
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1104, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1103, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
return ret;
}
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
return ret;
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 177, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h400c17219073e521);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 198, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
return ret;
}
@@ -746,8 +749,8 @@ function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg
}
}
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
Binary file not shown.
+1 -1
View File
@@ -17,7 +17,7 @@ export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
export const sqlite3_os_end: () => number;
export const sqlite3_os_init: () => number;
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h400c17219073e521: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
+30
View File
@@ -43,6 +43,10 @@ struct Host {
events: mpsc::Receiver<ModelPayload>,
}
fn lifecycle_host() -> yaak_lifecycle::Host {
yaak_lifecycle::Host::owner()
}
thread_local! {
static HOST: RefCell<Option<Host>> = const { RefCell::new(None) };
}
@@ -91,6 +95,10 @@ pub async fn boot() -> Result<()> {
let (queries, blobs, events) =
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
if let Err(e) = yaak_lifecycle::on_launch(&lifecycle_host(), &queries.connect(), &blobs) {
web_sys::console::warn_2(&"on_launch hook failed".into(), &js_error(e));
}
HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events }));
Ok(())
}
@@ -312,6 +320,28 @@ fn dispatch(
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
}
"cmd_delete_all_http_responses" => {
let req: RequestIdReq = from_js(payload)?;
host.queries
.connect()
.delete_all_http_responses_for_request(&req.request_id, source)
.map_err(js_error)?;
to_json(())
}
"cmd_delete_send_history" => {
let req: WorkspaceIdReq = from_js(payload)?;
host.queries
.with_tx(|tx| {
tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?;
tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?;
tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?;
Ok::<(), yaak_models::error::Error>(())
})
.map_err(js_error)?;
to_json(())
}
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
}
}
-1
View File
@@ -21,6 +21,5 @@ yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+82 -779
View File
@@ -1,826 +1,129 @@
use crate::Result;
use log::info;
use std::collections::{BTreeMap, BTreeSet};
use yaak_models::client_db::ClientDb;
use std::collections::BTreeMap;
use yaak_core::WorkspaceContext;
use yaak_models::models::{
DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo,
WebsocketRequest, Workspace,
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanResources, ImportPlanWarning,
PlannedImportResource, UpdateSource,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct PlanImportDataParams<'a> {
pub struct ImportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub destination: ImportDestination,
pub workspace_context: WorkspaceContext,
pub contents: &'a str,
}
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
let import_result =
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
plan_import_resources(
params.query_manager,
import_result.importer,
params.destination,
import_result.resources,
)
import_resources(params.query_manager, params.workspace_context, import_result.resources)
}
/// Remap parsed importer resources into their selected destination.
///
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
/// existing model and also makes the plan safe to inspect before it is committed.
pub fn plan_import_resources(
pub fn import_resources(
query_manager: &QueryManager,
importer: String,
destination: ImportDestination,
workspace_context: WorkspaceContext,
resources: ImportResources,
) -> Result<ImportPlan> {
let mut warnings = Vec::new();
validate_destination(query_manager, &destination)?;
) -> Result<BatchUpsertResult> {
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
let mut folder_ids = BTreeMap::new();
for folder in &resources.folders {
folder_ids.insert(folder.id.clone(), Folder::generate_id());
}
let mut workspace_ids = BTreeMap::new();
let mut workspaces = Vec::new();
let (default_workspace_id, target_folder_id) = match &destination {
ImportDestination::NewWorkspace => {
for source in &resources.workspaces {
let mut workspace = source.clone();
workspace.id = Workspace::generate_id();
workspace_ids.insert(source.id.clone(), workspace.id.clone());
workspaces.push(PlannedImportResource::new(workspace));
}
if workspaces.is_empty() {
let workspace = Workspace {
id: Workspace::generate_id(),
model: "workspace".to_string(),
name: format!("{} Import", display_importer_name(&importer)),
setting_follow_redirects: true,
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
setting_send_cookies: true,
setting_store_cookies: true,
..Default::default()
};
workspaces.push(PlannedImportResource::new(workspace));
}
(workspaces[0].resource.id.clone(), None)
}
ImportDestination::CurrentWorkspace { workspace_id, folder_id } => {
for source in &resources.workspaces {
workspace_ids.insert(source.id.clone(), workspace_id.clone());
}
if !resources.workspaces.is_empty() {
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
let skipped_fields = resources
.workspaces
.iter()
.flat_map(|source| {
workspace_fields_not_imported(source, &destination_workspace)
})
.collect::<BTreeSet<_>>();
if !skipped_fields.is_empty() {
let source = if resources.workspaces.len() == 1 {
resources.workspaces[0].name.clone()
} else {
format!("{} imported workspaces", resources.workspaces.len())
};
warnings.push(ImportPlanWarning {
title: "Workspace settings skipped".to_string(),
detail: format!("{source} · {}", display_list(&skipped_fields)),
});
}
}
(workspace_id.clone(), folder_id.clone())
}
};
let resolve_workspace_id = |source_id: &str| {
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
};
let resolve_folder_id = |source_id: Option<String>| match source_id {
Some(source_id) if source_folder_ids.contains(&source_id) => {
folder_ids.get(&source_id).cloned()
}
_ => target_folder_id.clone(),
};
let folders = resources
.folders
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut folder| {
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
folder.folder_id = resolve_folder_id(folder.folder_id);
PlannedImportResource::new(folder)
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
v
})
.collect();
let http_requests = resources
.http_requests
.into_iter()
.map(|mut request| {
request.id = HttpRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
let grpc_requests = resources
.grpc_requests
.into_iter()
.map(|mut request| {
request.id = GrpcRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
let websocket_requests = resources
.websocket_requests
.into_iter()
.map(|mut request| {
request.id = WebsocketRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
let importing_into_current = matches!(destination, ImportDestination::CurrentWorkspace { .. });
let mut separated_base_environments = Vec::new();
let mut converted_duplicate_base_environment = false;
let mut converted_duplicate_folder_environment = false;
let mut base_environment_workspaces = BTreeSet::new();
let mut folder_environment_ids = BTreeSet::new();
let environments = resources
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut environment| {
environment.id = Environment::generate_id();
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
("workspace", _) if importing_into_current => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
let source_name = environment.name.clone();
environment.name = format!("{} (Imported)", environment.name);
separated_base_environments.push((
source_name,
environment.name.clone(),
environment.variables.len(),
));
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id =
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
}
("workspace", _) => {
environment.parent_id = None;
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
environment.parent_model = "environment".to_string();
environment.name = format!("{} (Imported)", environment.name);
converted_duplicate_base_environment = true;
}
}
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
environment.parent_id = folder_ids.get(&parent_id).cloned();
if let Some(parent_id) = &environment.parent_id
&& !folder_environment_ids.insert(parent_id.clone())
{
environment.parent_model = "environment".to_string();
environment.parent_id = None;
converted_duplicate_folder_environment = true;
}
}
("folder", _) => {
// Never attach an imported folder environment to an existing folder: the model
// layer permits only one and would otherwise delete the destination's value.
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
("environment", _) => {
environment.parent_id = None;
("", _) => {
v.parent_model = "workspace".to_string();
}
_ => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
v.parent_id = None;
}
}
PlannedImportResource::new(environment)
};
v
})
.collect();
for (source_name, imported_name, variable_count) in separated_base_environments {
let variables = if variable_count == 1 { "variable" } else { "variables" };
warnings.push(ImportPlanWarning {
title: "Base environment kept separate".to_string(),
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
});
}
if converted_duplicate_base_environment {
warnings.push(ImportPlanWarning {
title: "Base environments separated".to_string(),
detail: "Only the first remains the base environment".to_string(),
});
}
if converted_duplicate_folder_environment {
warnings.push(ImportPlanWarning {
title: "Folder environments separated".to_string(),
detail: "Only the first remains attached to each folder".to_string(),
});
}
let folders: Vec<Folder> = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
Ok(ImportPlan {
importer,
destination,
resources: ImportPlanResources {
let http_requests: Vec<HttpRequest> = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
info!("Importing data");
query_manager.with_tx(|tx| {
tx.batch_upsert(
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
},
warnings,
})
}
/// Commit a previously prepared plan in one transaction.
pub fn commit_import_plan(
query_manager: &QueryManager,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
validate_plan(&plan)?;
let resources = plan.resources.into_batch();
info!("Committing staged import from {}", plan.importer);
query_manager.with_tx(|tx| {
validate_destination_db(tx, &plan.destination)?;
tx.batch_upsert(
resources.workspaces,
resources.environments,
resources.folders,
resources.http_requests,
resources.grpc_requests,
resources.websocket_requests,
&UpdateSource::Import,
)
.map_err(crate::Error::from)
})
}
fn validate_destination(
query_manager: &QueryManager,
destination: &ImportDestination,
) -> Result<()> {
let db = query_manager.connect();
validate_destination_db(&db, destination)
}
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
let ImportDestination::CurrentWorkspace { workspace_id, folder_id } = destination else {
return Ok(());
};
db.get_workspace(workspace_id)?;
if let Some(folder_id) = folder_id {
let folder = db.get_folder(folder_id)?;
if folder.workspace_id != *workspace_id {
return Err(yaak_models::error::Error::GenericError(format!(
"Folder {folder_id} does not belong to workspace {workspace_id}"
))
.into());
}
}
Ok(())
}
fn validate_plan(plan: &ImportPlan) -> Result<()> {
let invalid = |message: String| -> Result<()> {
Err(yaak_models::error::Error::GenericError(message).into())
};
match &plan.destination {
ImportDestination::CurrentWorkspace { workspace_id, .. } => {
if !plan.resources.workspaces.is_empty() {
return invalid(
"A current-workspace import plan must not contain workspace updates"
.to_string(),
);
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| &v.resource.workspace_id)
.chain(plan.resources.folders.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.http_requests.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.grpc_requests.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.websocket_requests.iter().map(|v| &v.resource.workspace_id));
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
return invalid(
"A current-workspace import plan contains resources for another workspace"
.to_string(),
);
}
if plan.resources.environments.iter().any(|v| v.resource.parent_model == "workspace") {
return invalid(
"A current-workspace import plan must not replace the base environment"
.to_string(),
);
}
}
ImportDestination::NewWorkspace => {
let workspace_ids = plan
.resources
.workspaces
.iter()
.map(|v| v.resource.id.as_str())
.collect::<BTreeSet<_>>();
if workspace_ids.is_empty() {
return invalid("A new-workspace import plan has no workspace".to_string());
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| v.resource.workspace_id.as_str())
.chain(plan.resources.folders.iter().map(|v| v.resource.workspace_id.as_str()))
.chain(
plan.resources.http_requests.iter().map(|v| v.resource.workspace_id.as_str()),
)
.chain(
plan.resources.grpc_requests.iter().map(|v| v.resource.workspace_id.as_str()),
)
.chain(
plan.resources
.websocket_requests
.iter()
.map(|v| v.resource.workspace_id.as_str()),
);
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
return invalid(
"A new-workspace import plan contains resources outside its workspaces"
.to_string(),
);
}
let mut base_environment_workspaces = BTreeSet::new();
if plan.resources.environments.iter().any(|v| {
v.resource.parent_model == "workspace"
&& !base_environment_workspaces.insert(v.resource.workspace_id.as_str())
}) {
return invalid(
"A new-workspace import plan contains multiple base environments for one workspace"
.to_string(),
);
}
}
}
let planned_folder_ids =
plan.resources.folders.iter().map(|v| v.resource.id.as_str()).collect::<BTreeSet<_>>();
if plan.resources.environments.iter().any(|v| {
v.resource.parent_model == "folder"
&& v.resource.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
}) {
return invalid(
"An import plan must not replace an existing folder environment".to_string(),
);
}
Ok(())
}
fn display_importer_name(importer: &str) -> &str {
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
}
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
let mut fields = Vec::new();
if source.name != destination.name {
fields.push("workspace name");
}
if source.description != destination.description {
fields.push("description");
}
if source.authentication != destination.authentication
|| source.authentication_type != destination.authentication_type
{
fields.push("authentication");
}
if source.headers != destination.headers {
fields.push("default headers");
}
if source.encryption_key_challenge != destination.encryption_key_challenge {
fields.push("encryption configuration");
}
if source.setting_validate_certificates != destination.setting_validate_certificates {
fields.push("certificate validation");
}
if source.setting_follow_redirects != destination.setting_follow_redirects {
fields.push("redirect behavior");
}
if source.setting_request_timeout != destination.setting_request_timeout {
fields.push("request timeout");
}
if source.setting_request_message_size != destination.setting_request_message_size {
fields.push("request message size");
}
if source.setting_dns_overrides != destination.setting_dns_overrides {
fields.push("DNS overrides");
}
if source.setting_send_cookies != destination.setting_send_cookies
|| source.setting_store_cookies != destination.setting_store_cookies
{
fields.push("cookie behavior");
}
fields
}
fn display_list(items: &BTreeSet<&str>) -> String {
let items = items.iter().copied().collect::<Vec<_>>();
match items.as_slice() {
[] => String::new(),
[item] => (*item).to_string(),
[first, second] => format!("{first} and {second}"),
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
fn destination_workspace() -> Workspace {
Workspace {
id: "wk_destination".to_string(),
model: "workspace".to_string(),
name: "Destination".to_string(),
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
authentication_type: Some("bearer".to_string()),
headers: vec![HttpRequestHeader {
enabled: true,
name: "X-Destination".to_string(),
value: "preserved".to_string(),
id: None,
}],
setting_validate_certificates: false,
setting_follow_redirects: false,
setting_request_timeout: 1234,
..Default::default()
}
}
fn imported_resources() -> ImportResources {
ImportResources {
workspaces: vec![Workspace {
id: "wk_source".to_string(),
model: "workspace".to_string(),
name: "Imported".to_string(),
authentication_type: Some("basic".to_string()),
setting_validate_certificates: true,
..Default::default()
}],
environments: vec![Environment {
id: "ev_source_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "imported".to_string(),
value: "yes".to_string(),
id: None,
}],
..Default::default()
}],
folders: vec![Folder {
id: "fl_source".to_string(),
model: "folder".to_string(),
workspace_id: "wk_source".to_string(),
name: "Imported Folder".to_string(),
..Default::default()
}],
http_requests: vec![
HttpRequest {
id: "rq_root".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
name: "Root Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/root".to_string(),
..Default::default()
},
HttpRequest {
id: "rq_nested".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
folder_id: Some("fl_source".to_string()),
name: "Nested Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/nested".to_string(),
..Default::default()
},
],
..Default::default()
}
}
#[test]
fn current_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut destination = destination_workspace();
let selected_folder = Folder {
id: "fl_selected".to_string(),
model: "folder".to_string(),
workspace_id: destination.id.clone(),
name: "Selected Folder".to_string(),
..Default::default()
};
{
let db = query_manager.connect();
destination = db
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
db.upsert_folder(&selected_folder, &UpdateSource::Import)
.expect("create selected folder");
db.upsert_environment(
&Environment {
id: "ev_destination_base".to_string(),
model: "environment".to_string(),
workspace_id: destination.id.clone(),
name: "Destination Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "destination".to_string(),
value: "keep".to_string(),
id: None,
}],
..Default::default()
},
&UpdateSource::Import,
)
.expect("create base environment");
}
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::CurrentWorkspace {
workspace_id: destination.id.clone(),
folder_id: Some(selected_folder.id.clone()),
},
imported_resources(),
)
.expect("plan import");
// Planning performed only reads.
{
let db = query_manager.connect();
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
assert_eq!(
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
1
);
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
}
assert!(plan.resources.workspaces.is_empty());
assert_eq!(plan.resources.folders[0].resource.workspace_id, destination.id);
assert_eq!(
plan.resources.folders[0].resource.folder_id.as_deref(),
Some(selected_folder.id.as_str())
);
let root_request = plan
.resources
.http_requests
.iter()
.find(|v| v.resource.name == "Root Request")
.expect("root request");
assert_eq!(root_request.resource.folder_id.as_deref(), Some(selected_folder.id.as_str()));
let nested_request = plan
.resources
.http_requests
.iter()
.find(|v| v.resource.name == "Nested Request")
.expect("nested request");
assert_eq!(
nested_request.resource.folder_id,
Some(plan.resources.folders[0].resource.id.clone())
);
assert_eq!(plan.resources.environments[0].resource.parent_model, "environment");
assert!(plan.resources.environments[0].resource.name.ends_with("(Imported)"));
assert_eq!(plan.warnings.len(), 2);
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Workspace settings skipped"
&& warning.detail.starts_with("Imported ·")
&& warning.detail.contains("authentication")
&& warning.detail.contains("default headers")
}));
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Base environment kept separate"
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
}));
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
assert!(committed.workspaces.is_empty());
assert_eq!(committed.http_requests.len(), 2);
assert_eq!(
query_manager
.connect()
.get_workspace(&destination.id)
.expect("get destination after commit"),
destination
);
}
#[test]
fn environment_collisions_are_explicit_and_do_not_overwrite() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.environments.extend([
Environment {
id: "ev_second_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Second Base".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
Environment {
id: "ev_folder_one".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder One".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
Environment {
id: "ev_folder_two".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder Two".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
]);
let plan = plan_import_resources(
&query_manager,
"Yaak".to_string(),
ImportDestination::NewWorkspace,
resources,
)
.expect("plan import");
assert_eq!(
plan.resources
.environments
.iter()
.filter(|v| v.resource.parent_model == "workspace")
.count(),
1
);
assert_eq!(
plan.resources
.environments
.iter()
.filter(|v| v.resource.parent_model == "folder")
.count(),
1
);
assert_eq!(plan.warnings.len(), 2);
}
#[test]
fn importer_id_conventions_all_flow_through_the_same_planner() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let destination = destination_workspace();
query_manager
.connect()
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
let resources = ImportResources {
workspaces: vec![
Workspace {
id: "GENERATE_ID::WORKSPACE_0".to_string(),
model: "workspace".to_string(),
name: "Generated ID Importer".to_string(),
..Default::default()
},
Workspace {
id: "wk_exported".to_string(),
model: "workspace".to_string(),
name: "Stable ID Importer".to_string(),
..Default::default()
},
],
http_requests: [
"GENERATE_ID::WORKSPACE_0",
"wk_exported",
"CURRENT_WORKSPACE",
]
.into_iter()
.enumerate()
.map(|(index, workspace_id)| HttpRequest {
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
model: "http_request".to_string(),
workspace_id: workspace_id.to_string(),
name: format!("Request {index}"),
method: "GET".to_string(),
..Default::default()
})
.collect(),
..Default::default()
};
let plan = plan_import_resources(
&query_manager,
"Compatibility".to_string(),
ImportDestination::CurrentWorkspace {
workspace_id: destination.id.clone(),
folder_id: None,
},
resources,
)
.expect("plan import");
assert!(plan.resources.workspaces.is_empty());
assert!(
plan.resources.http_requests.iter().all(|v| v.resource.workspace_id == destination.id)
);
assert_eq!(
plan.resources
.http_requests
.iter()
.map(|v| v.resource.id.as_str())
.collect::<BTreeSet<_>>()
.len(),
3
);
}
#[test]
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
let dir = tempfile::tempdir().expect("create temp directory");
let db_path = dir.path().join("models.sqlite");
let blob_path = dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
)
.expect("plan import");
let workspace_id = plan.resources.workspaces[0].resource.id.clone();
let environment_id = plan.resources.environments[0].resource.id.clone();
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
connection
.execute_batch(&format!(
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
))
.expect("install failure trigger");
drop(connection);
assert!(commit_import_plan(&query_manager, plan).is_err());
let db = query_manager.connect();
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
}
}
+1
View File
@@ -59,6 +59,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
timeline: true,
multiWindow: true,
windowChrome: true,
interfaceZoom: true,
plugins: true,
encryption: true,
updater: true,
+5
View File
@@ -262,6 +262,11 @@ export interface PlatformCapabilities {
* chrome should be reserved or drawn.
*/
windowChrome: boolean;
/**
* The app zooms its own interface, and so owns Cmd/Ctrl `+`, `-` and `0`.
* False in a browser, where those keys are already the browser's.
*/
interfaceZoom: boolean;
/** The plugin runtime. */
plugins: boolean;
/** Workspace encryption backed by a key the host keeps. */
+4 -1
View File
@@ -129,7 +129,10 @@ Reported honestly, so callers gate on the question rather than on the host:
| True | False |
| --- | --- |
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `interfaceZoom`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
`interfaceZoom: false` leaves Cmd/Ctrl `+`, `-` and `0` to the browser instead
of swallowing them, and drops those three rows from the hotkeys screen.
`multiWindow: false` means the host cannot open a *second window* on demand —
what `cmd_new_child_window` does for Settings and workspace switching. It is not
+2 -5
View File
@@ -65,6 +65,8 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload),
cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload),
/* -------------------------------- app ---------------------------------- */
@@ -261,7 +263,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the send proxy, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
@@ -299,10 +300,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
// Sending history and its bookkeeping belong to the send slice.
cmd_delete_send_history: ["Sending isn't available in the browser yet", null],
cmd_delete_all_http_responses: ["Sending isn't available in the browser yet", null],
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
};
+3
View File
@@ -52,6 +52,9 @@ function capabilitiesFor(): PlatformCapabilities {
// The browser draws the frame around the page. There are no traffic lights
// to leave room for and no window controls to draw.
windowChrome: false,
// The browser already zooms the page, on the same keys, and remembers it
// per site. The app stays out of the way.
interfaceZoom: false,
plugins: false,
encryption: false,
updater: false,
+1 -1
View File
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { importer: string, resources: ImportResources, };
export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
@@ -167,7 +167,6 @@ export class PluginInstance {
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: "import_response",
importer: this.#mod.importer.name,
resources: reply.resources as ImportResources,
};
this.#sendPayload(context, replyPayload, replyId);
+7 -1
View File
@@ -31,7 +31,13 @@ export async function fetchAccessToken(
],
};
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
// RFC 6749 §4.1.3 doesn't define scope for the authorization code token
// request, so strict servers (OpenIddict) reject it outright. Scope belongs on
// the authorize request, which already sends it. Every other grant does define
// it: §4.3.2 password, §4.4.2 client credentials, §6 refresh.
if (scope && grantType !== "authorization_code") {
httpRequest.body?.form.push({ name: "scope", value: scope });
}
if (audience) httpRequest.body?.form.push({ name: "audience", value: audience });
if ("clientAssertion" in args) {
@@ -0,0 +1,93 @@
import type { HttpRequest } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { fetchAccessToken } from "../src/fetchAccessToken";
/**
* Captures the request handed to ctx.httpRequest.send so tests can assert on the
* form body, and replies with a minimal successful token response.
*/
function createMockContext() {
const sent: Partial<HttpRequest>[] = [];
const ctx = {
httpRequest: {
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
sent.push(httpRequest);
return {
httpResponse: { status: 200, error: null },
body: {
async text() {
return JSON.stringify({ access_token: "token-123" });
},
},
};
},
},
} as never;
return { ctx, sent };
}
function formNames(httpRequest: Partial<HttpRequest>) {
return (httpRequest.body?.form ?? []).map((p: { name: string }) => p.name);
}
function formValue(httpRequest: Partial<HttpRequest>, name: string) {
return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value;
}
const baseArgs = {
clientId: "client-123",
accessTokenUrl: "https://auth.example.com/token",
scope: "openid profile",
audience: null,
clientSecret: "secret",
credentialsInBody: true,
params: [],
};
describe("fetchAccessToken scope handling", () => {
test("omits scope for the authorization code grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "authorization_code",
params: [{ name: "code", value: "abc" }],
});
expect(formNames(sent[0]!)).not.toContain("scope");
// The rest of the request is untouched
expect(formValue(sent[0]!, "grant_type")).toBe("authorization_code");
expect(formValue(sent[0]!, "code")).toBe("abc");
});
test("sends scope for the client credentials grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, { ...baseArgs, grantType: "client_credentials" });
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
});
test("sends scope for the password grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, { ...baseArgs, grantType: "password" });
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
});
test("still sends audience for the authorization code grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "authorization_code",
audience: "https://api.example.com",
});
expect(formValue(sent[0]!, "audience")).toBe("https://api.example.com");
expect(formNames(sent[0]!)).not.toContain("scope");
});
});