mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-15 05:02:16 +01:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fa604022 | ||
|
|
9d16dc87fe | ||
|
|
ba6a8e39eb | ||
|
|
8eb2368151 | ||
|
|
34822442b2 | ||
|
|
5149427c1b | ||
|
|
bcb8b2e729 | ||
|
|
7c660f4528 | ||
|
|
0337ef35c9 | ||
|
|
602bd1bb45 | ||
|
|
57ab349901 | ||
|
|
7a6d275fdb | ||
|
|
7d0926c961 | ||
|
|
522b898620 | ||
|
|
cb7ed9264d | ||
|
|
90cb578e26 | ||
|
|
ff7eebf3cd | ||
|
|
eff0c06cb0 | ||
|
|
c6e09a5af3 | ||
|
|
b05071ef4b | ||
|
|
a44f31eeb2 | ||
|
|
1103d05915 | ||
|
|
2f5639a26f |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Generated
+51
-28
@@ -960,15 +960,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.1"
|
||||
@@ -978,16 +969,6 @@ dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
@@ -2114,7 +2095,7 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c169adf067a787e1f1c58ed62906a557de85388bee4b54fb878b722ff606b113"
|
||||
dependencies = [
|
||||
"bzip2 0.6.1",
|
||||
"bzip2",
|
||||
"clap",
|
||||
"fs-err",
|
||||
"indicatif",
|
||||
@@ -2914,11 +2895,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi 0.14.2+wasi-0.2.4",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4194,6 +4173,15 @@ dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -6174,6 +6162,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -8906,7 +8900,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11376,7 +11370,7 @@ dependencies = [
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
"zip",
|
||||
"zip 7.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12046,13 +12040,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.0.0"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2 0.5.2",
|
||||
"bzip2",
|
||||
"constant_time_eq 0.3.1",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
@@ -12063,6 +12057,35 @@ dependencies = [
|
||||
"liblzma",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"time",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq 0.3.1",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"generic-array",
|
||||
"getrandom 0.3.3",
|
||||
"hmac",
|
||||
"indexmap 2.14.0",
|
||||
"lzma-rust2",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"time",
|
||||
"zeroize",
|
||||
@@ -12078,7 +12101,7 @@ checksum = "aed5f10c571472911e37d8f7601a8dfba52b4f7f73a344015291b82ab292faf6"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"thiserror 2.0.17",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
|
||||
|
||||
## Features
|
||||
|
||||
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
||||
@@ -68,3 +72,9 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
## License
|
||||
|
||||
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
|
||||
|
||||
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
|
||||
@@ -133,6 +133,8 @@ export function GrpcRequestPane({
|
||||
onSend({ message: activeRequest.message });
|
||||
}, [activeRequest, onSend]);
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{ value: TAB_MESSAGE, label: "Message" },
|
||||
@@ -146,10 +148,10 @@ export function GrpcRequestPane({
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
|
||||
[authTab, hasDescription, metadataTab, numSettingsOverrides],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
|
||||
@@ -165,6 +165,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
: 0;
|
||||
}
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -260,6 +262,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -267,6 +270,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
authTab,
|
||||
contentType,
|
||||
handleContentTypeChange,
|
||||
hasDescription,
|
||||
headersTab,
|
||||
numParams,
|
||||
numSettingsOverrides,
|
||||
|
||||
@@ -110,6 +110,8 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -131,9 +133,10 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
];
|
||||
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
|
||||
@@ -205,6 +205,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
writingsuggestions="false"
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
||||
|
||||
@@ -99,6 +99,6 @@
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.2.9"
|
||||
"vite-plus": "^0.3.0"
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import "react";
|
||||
|
||||
declare module "react" {
|
||||
interface HTMLAttributes<T> {
|
||||
// Not in @types/react yet; opts editable elements out of macOS Writing Tools
|
||||
writingsuggestions?: "true" | "false";
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,6 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9"
|
||||
"vite-plus": "^0.3.0"
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ tokio = { workspace = true, features = [
|
||||
] }
|
||||
walkdir = "2"
|
||||
webbrowser = "1"
|
||||
zip = "4"
|
||||
zip = "7"
|
||||
yaak = { workspace = true }
|
||||
yaak-api = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
|
||||
@@ -49,8 +49,11 @@ fn schema(pretty: bool) -> CommandResult {
|
||||
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
|
||||
let workspace_id = resolve_workspace_id(ctx, workspace_id, "environment list")?;
|
||||
let environments = ctx
|
||||
.db()
|
||||
.list_environments_ensure_base(&workspace_id)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.ensure_base_environment(&workspace_id)?;
|
||||
tx.list_environments(&workspace_id)
|
||||
})
|
||||
.map_err(|e| format!("Failed to list environments: {e}"))?;
|
||||
|
||||
if environments.is_empty() {
|
||||
@@ -111,8 +114,8 @@ fn create(
|
||||
}
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_environment(&environment, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create environment: {e}"))?;
|
||||
|
||||
println!("Created environment: {}", created.id);
|
||||
@@ -133,8 +136,8 @@ fn create(
|
||||
};
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_environment(&environment, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create environment: {e}"))?;
|
||||
|
||||
println!("Created environment: {}", created.id);
|
||||
@@ -152,8 +155,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "environment update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_environment(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_environment(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update environment: {e}"))?;
|
||||
|
||||
println!("Updated environment: {}", saved.id);
|
||||
@@ -167,8 +170,8 @@ fn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_environment_by_id(environment_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_environment_by_id(environment_id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete environment: {e}"))?;
|
||||
|
||||
println!("Deleted environment: {}", deleted.id);
|
||||
|
||||
@@ -102,8 +102,8 @@ fn create(
|
||||
)?;
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_folder(&folder, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create folder: {e}"))?;
|
||||
|
||||
println!("Created folder: {}", created.id);
|
||||
@@ -118,8 +118,8 @@ fn create(
|
||||
let folder = Folder { workspace_id, name, ..Default::default() };
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_folder(&folder, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create folder: {e}"))?;
|
||||
|
||||
println!("Created folder: {}", created.id);
|
||||
@@ -135,8 +135,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "folder update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_folder(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_folder(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update folder: {e}"))?;
|
||||
|
||||
println!("Updated folder: {}", saved.id);
|
||||
@@ -150,8 +150,8 @@ fn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_folder_by_id(folder_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_folder_by_id(folder_id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete folder: {e}"))?;
|
||||
|
||||
println!("Deleted folder: {}", deleted.id);
|
||||
|
||||
@@ -68,7 +68,9 @@ async fn import(
|
||||
);
|
||||
}
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||
Some(workspace_id) => {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
|
||||
}
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let plan = import::plan_import_resources(
|
||||
|
||||
@@ -348,17 +348,19 @@ async fn install_from_directory(context: &CliContext, source: &str) -> CommandRe
|
||||
ui::info(&format!("Installing plugin from directory {}", plugin_dir.display()));
|
||||
|
||||
let plugin = context
|
||||
.db()
|
||||
.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: plugin_dir_str,
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: plugin_dir_str,
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
})
|
||||
.map_err(|err| format!("Failed to save plugin in database: {err}"))?;
|
||||
|
||||
let plugin_context = PluginContext::new(Some("cli".to_string()), None);
|
||||
@@ -883,11 +885,8 @@ mod tests {
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("vendor")).expect("create vendor");
|
||||
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
|
||||
@@ -424,8 +424,8 @@ fn create(
|
||||
)?;
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create request: {e}"))?;
|
||||
|
||||
println!("Created request: {}", created.id);
|
||||
@@ -443,8 +443,8 @@ fn create(
|
||||
}
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create request: {e}"))?;
|
||||
|
||||
println!("Created request: {}", created.id);
|
||||
@@ -463,8 +463,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "request update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_http_request(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_http_request(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update request: {e}"))?;
|
||||
|
||||
println!("Updated request: {}", saved.id);
|
||||
@@ -487,8 +487,8 @@ fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_http_request_by_id(request_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_http_request_by_id(request_id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete request: {e}"))?;
|
||||
println!("Deleted request: {}", deleted.id);
|
||||
Ok(())
|
||||
|
||||
@@ -118,8 +118,8 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
|
||||
return Ok(());
|
||||
}
|
||||
let count = ctx
|
||||
.db()
|
||||
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_all_http_responses_for_request(id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||
println!("Deleted {count} responses for request {id}");
|
||||
return Ok(());
|
||||
@@ -131,8 +131,10 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
|
||||
return Ok(());
|
||||
}
|
||||
let count = ctx
|
||||
.db()
|
||||
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||
println!("Deleted {count} responses for workspace {workspace_id}");
|
||||
Ok(())
|
||||
|
||||
@@ -84,8 +84,8 @@ fn create(
|
||||
.map_err(|e| format!("Failed to parse workspace create JSON: {e}"))?;
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_workspace(&workspace, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create workspace: {e}"))?;
|
||||
println!("Created workspace: {}", created.id);
|
||||
return Ok(());
|
||||
@@ -97,8 +97,8 @@ fn create(
|
||||
|
||||
let workspace = Workspace { name, ..Default::default() };
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_workspace(&workspace, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create workspace: {e}"))?;
|
||||
println!("Created workspace: {}", created.id);
|
||||
Ok(())
|
||||
@@ -115,8 +115,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "workspace update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_workspace(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_workspace(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update workspace: {e}"))?;
|
||||
|
||||
println!("Updated workspace: {}", saved.id);
|
||||
@@ -130,8 +130,10 @@ fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
|
||||
println!("Deleted workspace: {}", deleted.id);
|
||||
Ok(())
|
||||
|
||||
@@ -51,11 +51,9 @@ impl CliContext {
|
||||
};
|
||||
|
||||
// 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 _ = query_manager.with_tx(|tx| {
|
||||
yaak_lifecycle::on_launch(&yaak_lifecycle::Host::guest(), tx, &blob_manager)
|
||||
});
|
||||
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
@@ -24,6 +23,7 @@ use yaak_models::models::Environment;
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
|
||||
|
||||
@@ -40,8 +40,7 @@ pub fn seed_workspace(data_dir: &Path, workspace_id: &str) {
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_workspace(&workspace, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
|
||||
.expect("Failed to seed workspace");
|
||||
}
|
||||
|
||||
@@ -56,8 +55,7 @@ pub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed request");
|
||||
}
|
||||
|
||||
@@ -70,8 +68,7 @@ pub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) {
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_folder(&folder, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
|
||||
.expect("Failed to seed folder");
|
||||
}
|
||||
|
||||
@@ -85,8 +82,7 @@ pub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str)
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_grpc_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_grpc_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed gRPC request");
|
||||
}
|
||||
|
||||
@@ -100,7 +96,6 @@ pub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: &
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_websocket_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_websocket_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed WebSocket request");
|
||||
}
|
||||
@@ -161,8 +161,7 @@ fn import_postman_environment_uses_workspace_id() {
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let environments =
|
||||
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
|
||||
let environments = db.list_environments(&workspace_id).expect("list imported environments");
|
||||
|
||||
let imported_environment =
|
||||
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||
@@ -210,7 +209,10 @@ fn re_import_merges_into_linked_workspace() {
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
|
||||
@@ -273,7 +275,10 @@ fn re_import_leaves_deleted_resources_alone() {
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
@@ -293,7 +298,9 @@ fn re_import_leaves_deleted_resources_alone() {
|
||||
.into_iter()
|
||||
.find(|r| r.name == "Request B")
|
||||
.expect("request B imported");
|
||||
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync)
|
||||
drop(db);
|
||||
query_manager
|
||||
.with_tx(|tx| tx.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync))
|
||||
.expect("delete request B");
|
||||
workspace_id
|
||||
};
|
||||
|
||||
@@ -25,8 +25,7 @@ fn top_level_send_folder_sends_http_requests_and_prints_summary() {
|
||||
..Default::default()
|
||||
};
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed folder request");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
|
||||
@@ -145,11 +145,8 @@ async fn cache_control(req: Request, next: Next) -> Response {
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/html"));
|
||||
let value = if hashed_name && !is_html {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
let value =
|
||||
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
|
||||
res
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
|
||||
entries
|
||||
}
|
||||
|
||||
|
||||
pub(crate) async fn build_metadata<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
|
||||
@@ -177,4 +177,3 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
|
||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||
}
|
||||
|
||||
@@ -29,16 +29,16 @@ use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::send::ResponseBody;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_commands::resolve::resolve_grpc_request;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_common::command::new_checked_command;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||
use yaak_grpc::{Code, ServiceDefinition};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType,
|
||||
HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
@@ -330,18 +330,20 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let settings = app_handle.db().get_settings();
|
||||
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
|
||||
|
||||
let conn = app_handle.db().upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
status: -1,
|
||||
elapsed: 0,
|
||||
state: GrpcConnectionState::Initialized,
|
||||
url: request.url.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
let conn = app_handle.with_tx(|tx| {
|
||||
tx.upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
status: -1,
|
||||
elapsed: 0,
|
||||
state: GrpcConnectionState::Initialized,
|
||||
url: request.url.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let conn_id = conn.id.clone();
|
||||
|
||||
@@ -386,15 +388,17 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let connection = match connection {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
app_handle.db().upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
elapsed: start.elapsed().as_millis() as i32,
|
||||
error: Some(err.to_string()),
|
||||
state: GrpcConnectionState::Closed,
|
||||
..conn.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
app_handle.with_tx(|tx| {
|
||||
tx.upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
elapsed: start.elapsed().as_millis() as i32,
|
||||
error: Some(err.to_string()),
|
||||
state: GrpcConnectionState::Closed,
|
||||
..conn.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
return Ok(conn_id);
|
||||
}
|
||||
};
|
||||
@@ -495,15 +499,17 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
.await?;
|
||||
let msg = strip_json_comments(&msg);
|
||||
|
||||
app_handle.db().upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: format!("Connecting to {}", req.url),
|
||||
event_type: GrpcEventType::ConnectionStart,
|
||||
metadata: metadata.clone(),
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
app_handle.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: format!("Connecting to {}", req.url),
|
||||
event_type: GrpcEventType::ConnectionStart,
|
||||
metadata: metadata.clone(),
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
async move {
|
||||
// Create callback for streaming methods that handles both success and error
|
||||
@@ -513,24 +519,28 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let window_label = window.label().to_string();
|
||||
move |result: std::result::Result<String, String>| match result {
|
||||
Ok(msg) => {
|
||||
let _ = app_handle.db().upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: msg,
|
||||
event_type: GrpcEventType::ClientMessage,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
);
|
||||
let _ = app_handle.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: msg,
|
||||
event_type: GrpcEventType::ClientMessage,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app_handle.db().upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: format!("Failed to send message: {}", error),
|
||||
event_type: GrpcEventType::Error,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
);
|
||||
let _ = app_handle.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: format!("Failed to send message: {}", error),
|
||||
event_type: GrpcEventType::Error,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -583,36 +593,38 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
|
||||
if !method_desc.is_client_streaming() {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
event_type: GrpcEventType::ClientMessage,
|
||||
content: msg,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
event_type: GrpcEventType::ClientMessage,
|
||||
content: msg,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
match maybe_msg {
|
||||
Some(Ok(msg)) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
metadata: metadata_to_map(msg.metadata().clone()),
|
||||
content: if msg.metadata().len() == 0 {
|
||||
"Received response"
|
||||
} else {
|
||||
"Received response with metadata"
|
||||
}
|
||||
.to_string(),
|
||||
event_type: GrpcEventType::Info,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
metadata: metadata_to_map(msg.metadata().clone()),
|
||||
content: if msg.metadata().len() == 0 {
|
||||
"Received response"
|
||||
} else {
|
||||
"Received response with metadata"
|
||||
}
|
||||
.to_string(),
|
||||
event_type: GrpcEventType::Info,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let response_message = msg.into_inner();
|
||||
let content = match connection
|
||||
@@ -622,83 +634,88 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Failed to read response".to_string(),
|
||||
error: Some(err.to_string()),
|
||||
status: Some(Code::Internal as i32),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Failed to read response".to_string(),
|
||||
error: Some(err.to_string()),
|
||||
status: Some(Code::Internal as i32),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
};
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content,
|
||||
event_type: GrpcEventType::ServerMessage,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content,
|
||||
event_type: GrpcEventType::ServerMessage,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Connection complete".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
status: Some(Code::Ok as i32),
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Connection complete".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
status: Some(Code::Ok as i32),
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&(match e.status {
|
||||
Some(s) => GrpcEvent {
|
||||
error: Some(s.message().to_string()),
|
||||
status: Some(s.code() as i32),
|
||||
content: "Request failed".to_string(),
|
||||
metadata: metadata_to_map(s.metadata().clone()),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
None => GrpcEvent {
|
||||
error: Some(e.message),
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&(match e.status {
|
||||
Some(s) => GrpcEvent {
|
||||
error: Some(s.message().to_string()),
|
||||
status: Some(s.code() as i32),
|
||||
content: "Request failed".to_string(),
|
||||
metadata: metadata_to_map(s.metadata().clone()),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
None => GrpcEvent {
|
||||
error: Some(e.message),
|
||||
status: Some(Code::Unknown as i32),
|
||||
content: "Request failed".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
}),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
app_handle
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
error: Some(e.to_string()),
|
||||
status: Some(Code::Unknown as i32),
|
||||
content: "Request failed".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
}),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
error: Some(e.to_string()),
|
||||
status: Some(Code::Unknown as i32),
|
||||
content: "Request failed".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
None => {
|
||||
@@ -709,64 +726,67 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let mut stream = match maybe_stream {
|
||||
Some(Ok(stream)) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
metadata: metadata_to_map(stream.metadata().clone()),
|
||||
content: if stream.metadata().len() == 0 {
|
||||
"Received response"
|
||||
} else {
|
||||
"Received response with metadata"
|
||||
}
|
||||
.to_string(),
|
||||
event_type: GrpcEventType::Info,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
metadata: metadata_to_map(stream.metadata().clone()),
|
||||
content: if stream.metadata().len() == 0 {
|
||||
"Received response"
|
||||
} else {
|
||||
"Received response with metadata"
|
||||
}
|
||||
.to_string(),
|
||||
event_type: GrpcEventType::Info,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
stream.into_inner()
|
||||
}
|
||||
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
|
||||
warn!("GRPC stream error {e:?}");
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&(match e.status {
|
||||
Some(s) => GrpcEvent {
|
||||
error: Some(s.message().to_string()),
|
||||
status: Some(s.code() as i32),
|
||||
content: "Stream failed".to_string(),
|
||||
metadata: metadata_to_map(s.metadata().clone()),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
None => GrpcEvent {
|
||||
error: Some(e.message),
|
||||
status: Some(Code::Unknown as i32),
|
||||
content: "Stream failed".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
}),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&(match e.status {
|
||||
Some(s) => GrpcEvent {
|
||||
error: Some(s.message().to_string()),
|
||||
status: Some(s.code() as i32),
|
||||
content: "Stream failed".to_string(),
|
||||
metadata: metadata_to_map(s.metadata().clone()),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
None => GrpcEvent {
|
||||
error: Some(e.message),
|
||||
status: Some(Code::Unknown as i32),
|
||||
content: "Stream failed".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
}),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
error: Some(e.to_string()),
|
||||
status: Some(Code::Unknown as i32),
|
||||
content: "Stream failed".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
error: Some(e.to_string()),
|
||||
status: Some(Code::Unknown as i32),
|
||||
content: "Stream failed".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
@@ -783,65 +803,69 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
Ok(message) => message,
|
||||
Err(err) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Failed to read response".to_string(),
|
||||
error: Some(err.to_string()),
|
||||
status: Some(Code::Internal as i32),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Failed to read response".to_string(),
|
||||
error: Some(err.to_string()),
|
||||
status: Some(Code::Internal as i32),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
break;
|
||||
}
|
||||
};
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: message,
|
||||
event_type: GrpcEventType::ServerMessage,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: message,
|
||||
event_type: GrpcEventType::ServerMessage,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
Ok(None) => {
|
||||
let trailers =
|
||||
stream.trailers().await.unwrap_or_default().unwrap_or_default();
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Connection complete".to_string(),
|
||||
status: Some(Code::Ok as i32),
|
||||
metadata: metadata_to_map(trailers),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Connection complete".to_string(),
|
||||
status: Some(Code::Ok as i32),
|
||||
metadata: metadata_to_map(trailers),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
break;
|
||||
}
|
||||
Err(status) => {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Stream failed".to_string(),
|
||||
error: Some(status.message().to_string()),
|
||||
status: Some(status.code() as i32),
|
||||
metadata: metadata_to_map(status.metadata().clone()),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Stream failed".to_string(),
|
||||
error: Some(status.message().to_string()),
|
||||
status: Some(status.code() as i32),
|
||||
metadata: metadata_to_map(status.metadata().clone()),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
..base_event.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
break;
|
||||
}
|
||||
@@ -874,21 +898,24 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
}).unwrap();
|
||||
},
|
||||
_ = cancelled_rx.changed() => {
|
||||
w.db().upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Cancelled".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
status: Some(Code::Cancelled as i32),
|
||||
..base_msg.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
).unwrap();
|
||||
w.with_tx(|tx| {
|
||||
tx.upsert_grpc_event(
|
||||
&GrpcEvent {
|
||||
content: "Cancelled".to_string(),
|
||||
event_type: GrpcEventType::ConnectionEnd,
|
||||
status: Some(Code::Cancelled as i32),
|
||||
..base_msg.clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
w.with_tx(|c| {
|
||||
c.upsert_grpc_connection(
|
||||
&GrpcConnection{
|
||||
elapsed: start.elapsed().as_millis() as i32,
|
||||
status: Code::Cancelled as i32,
|
||||
state: GrpcConnectionState::Closed,
|
||||
elapsed: start.elapsed().as_millis() as i32,
|
||||
status: Code::Cancelled as i32,
|
||||
state: GrpcConnectionState::Closed,
|
||||
..c.get_grpc_connection( &conn_id).unwrap().clone()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
@@ -1033,20 +1060,6 @@ async fn cmd_commit_import<R: Runtime>(
|
||||
commit_import(&window, plan)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
|
||||
@@ -1080,15 +1093,17 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
let request = app_handle.db().get_http_request(&request_id)?;
|
||||
|
||||
let blobs = app_handle.blob_manager();
|
||||
let response = app_handle.db().upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
&blobs,
|
||||
)?;
|
||||
let response = app_handle.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
&blobs,
|
||||
)
|
||||
})?;
|
||||
|
||||
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
||||
app_handle.listen_any(format!("cancel_http_response_{}", response.id), move |_event| {
|
||||
@@ -1126,22 +1141,23 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(sent) => sent.response,
|
||||
Err(e) => {
|
||||
let resp = app_handle.db().get_http_response(&response.id)?;
|
||||
app_handle.db().upsert_http_response(
|
||||
&HttpResponse {
|
||||
state: HttpResponseState::Closed,
|
||||
error: Some(e.to_string()),
|
||||
..resp
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
&blobs,
|
||||
)?
|
||||
app_handle.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&HttpResponse {
|
||||
state: HttpResponseState::Closed,
|
||||
error: Some(e.to_string()),
|
||||
..resp
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
&blobs,
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
url: &str,
|
||||
@@ -1269,8 +1285,8 @@ pub fn run() {
|
||||
.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())
|
||||
if let Err(e) = app
|
||||
.with_tx(|tx| yaak_lifecycle::on_launch(&lifecycle_host, tx, &app.blob_manager()))
|
||||
{
|
||||
error!("on_launch hook failed: {e:?}");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Emitter, Manager, Runtime, State};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::client_db::{ClientDb, WriteDb};
|
||||
use yaak_models::error::Result;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
@@ -95,7 +95,7 @@ pub trait QueryManagerExt<'a, R> {
|
||||
fn db(&'a self) -> ClientDb<'a>;
|
||||
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&ClientDb) -> Result<T>;
|
||||
F: FnOnce(&WriteDb) -> Result<T>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
@@ -110,7 +110,7 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
|
||||
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&ClientDb) -> Result<T>,
|
||||
F: FnOnce(&WriteDb) -> Result<T>,
|
||||
{
|
||||
let qm = self.state::<QueryManager>();
|
||||
qm.inner().with_tx(func)
|
||||
|
||||
@@ -55,12 +55,15 @@ impl YaakNotifier {
|
||||
seen.push(id.to_string());
|
||||
debug!("Marked notification as seen {}", id);
|
||||
let seen_json = serde_json::to_string(&seen)?;
|
||||
window.db().set_key_value_raw(
|
||||
KV_NAMESPACE,
|
||||
KV_KEY,
|
||||
seen_json.as_str(),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
);
|
||||
window.with_tx(|tx| {
|
||||
tx.set_key_value_raw(
|
||||
KV_NAMESPACE,
|
||||
KV_KEY,
|
||||
seen_json.as_str(),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
}
|
||||
|
||||
let new_plugin = Plugin { updated_at: Utc::now().naive_utc(), ..plugin };
|
||||
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
|
||||
app_handle.with_tx(|tx| tx.upsert_plugin(&new_plugin, &UpdateSource::Plugin))?;
|
||||
}
|
||||
|
||||
if !req.silent {
|
||||
@@ -294,15 +294,17 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
HttpResponse::default()
|
||||
} else {
|
||||
let blobs = window.blob_manager();
|
||||
window.db().upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: http_request.id.clone(),
|
||||
workspace_id: http_request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
&blobs,
|
||||
)?
|
||||
window.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: http_request.id.clone(),
|
||||
workspace_id: http_request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
&blobs,
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
let http_response = send_http_request_with_context(
|
||||
|
||||
@@ -16,8 +16,7 @@ use std::time::{Duration, Instant};
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::plugin::{Builder, TauriPlugin};
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent,
|
||||
is_dev,
|
||||
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, is_dev,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use ts_rs::TS;
|
||||
@@ -28,10 +27,10 @@ use yaak_plugins::api::{
|
||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||
search_plugins,
|
||||
};
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
|
||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
|
||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
@@ -203,16 +202,18 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
// Resolve the manager before writing the row so startup's plugin snapshot
|
||||
// can't include it and boot it a second time
|
||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
||||
let plugin = window.db().upsert_plugin(
|
||||
&Plugin {
|
||||
directory: directory.into(),
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
let plugin = window.with_tx(|tx| {
|
||||
tx.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: directory.into(),
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
||||
|
||||
@@ -355,9 +356,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
|
||||
"Timed out starting the plugin runtime".to_string(),
|
||||
)));
|
||||
.unwrap_or_else(|_| {
|
||||
Err(yaak_plugins::error::Error::PluginErr(
|
||||
"Timed out starting the plugin runtime".to_string(),
|
||||
))
|
||||
});
|
||||
|
||||
let manager = match result {
|
||||
Ok(manager) => manager,
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||
//! need to track which is which.
|
||||
|
||||
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
@@ -21,9 +21,9 @@ use crate::notifications::YaakNotifier;
|
||||
use crate::updates::YaakUpdater;
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
@@ -32,8 +32,8 @@ use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
|
||||
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult,
|
||||
};
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{
|
||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
@@ -42,26 +42,26 @@ use yaak_models::models::{
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
GetWorkspaceActionsResponse,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
|
||||
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc::RpcRouter;
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_sync::sync::SyncOp;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_ws::WebsocketManager;
|
||||
|
||||
/// Per-call context: the window a command was invoked from.
|
||||
@@ -231,17 +231,15 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
async fn call_folder_action(&self, req: CallFolderActionRequest) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
let results =
|
||||
self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
@@ -393,11 +391,17 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
|
||||
Ok(crate::cmd_metadata(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
|
||||
async fn cmd_render_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdRenderTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -405,55 +409,114 @@ async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackRe
|
||||
Ok(crate::cmd_send_feedback(ctx.window.app_handle().clone(), req.feature, req.text).await?)
|
||||
}
|
||||
|
||||
async fn cmd_dismiss_notification<R: Runtime>(ctx: ClientCtx<R>, req: CmdDismissNotificationReq) -> Result<()> {
|
||||
Ok(crate::cmd_dismiss_notification(ctx.window.clone(), &req.notification_id, ctx.window.app_handle().state::<Mutex<YaakNotifier>>()).await?)
|
||||
async fn cmd_dismiss_notification<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDismissNotificationReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_dismiss_notification(
|
||||
ctx.window.clone(),
|
||||
&req.notification_id,
|
||||
ctx.window.app_handle().state::<Mutex<YaakNotifier>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_reflect<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcReflectReq) -> Result<Vec<ServiceDefinition>> {
|
||||
Ok(crate::cmd_grpc_reflect(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
|
||||
async fn cmd_grpc_reflect<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGrpcReflectReq,
|
||||
) -> Result<Vec<ServiceDefinition>> {
|
||||
Ok(crate::cmd_grpc_reflect(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.proto_files,
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_go<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcGoReq) -> Result<String> {
|
||||
Ok(crate::cmd_grpc_go(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
|
||||
Ok(crate::cmd_grpc_go(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.proto_files,
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_restart<R: Runtime>(ctx: ClientCtx<R>, _req: CmdRestartReq) -> Result<()> {
|
||||
Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<EphemeralHttpResponse> {
|
||||
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSendEphemeralRequestReq,
|
||||
) -> Result<EphemeralHttpResponse> {
|
||||
Ok(crate::cmd_send_ephemeral_request(
|
||||
req.request,
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().clone(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_json<R: Runtime>(ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(yaak_commands::data::cmd_format_json(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
|
||||
async fn cmd_format_graphql<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdFormatGraphqlReq,
|
||||
) -> Result<String> {
|
||||
Ok(crate::cmd_format_graphql(&req.text).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
|
||||
async fn cmd_http_response_body<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpResponseBodyReq,
|
||||
) -> Result<FilterResponse> {
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
||||
async fn cmd_http_response_body_path<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
|
||||
async fn cmd_http_request_body<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpRequestBodyReq,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
|
||||
async fn cmd_get_sse_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetSseEventsReq,
|
||||
) -> Result<Vec<ServerSentEvent>> {
|
||||
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
|
||||
async fn cmd_get_http_response_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpResponseEventsReq,
|
||||
) -> Result<Vec<HttpResponseEvent>> {
|
||||
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> {
|
||||
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?)
|
||||
}
|
||||
|
||||
@@ -461,16 +524,25 @@ async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) ->
|
||||
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> {
|
||||
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_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
|
||||
async fn cmd_list_import_sources<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdListImportSourcesReq,
|
||||
) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
|
||||
}
|
||||
|
||||
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
|
||||
async fn cmd_import_sources_for_origin<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdImportSourcesForOriginReq,
|
||||
) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
let origin = match (req.file_path, req.url) {
|
||||
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
|
||||
@@ -483,67 +555,115 @@ async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdIm
|
||||
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
async fn cmd_http_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWebsocketRequestActionsReq,
|
||||
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallWebsocketRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
async fn cmd_workspace_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
|
||||
async fn cmd_call_workspace_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallWorkspaceActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
async fn cmd_folder_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdFolderActionsReq,
|
||||
) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
|
||||
async fn cmd_call_folder_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallFolderActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGrpcRequestActionsReq,
|
||||
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
async fn cmd_template_function_summaries<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
async fn cmd_template_function_config<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_http_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallGrpcRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
|
||||
async fn cmd_curl_to_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCurlToRequestReq,
|
||||
) -> Result<HttpRequest> {
|
||||
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -551,83 +671,159 @@ async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -
|
||||
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
|
||||
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data).await?)
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSaveBase64ToBinaryReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_response<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveResponseReq) -> Result<()> {
|
||||
Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
|
||||
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
|
||||
async fn cmd_send_http_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSendHttpRequestReq,
|
||||
) -> Result<HttpResponse> {
|
||||
Ok(crate::cmd_send_http_request(
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
req.request_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdReloadPluginsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
|
||||
async fn cmd_plugin_info<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginInfoReq,
|
||||
) -> Result<PluginMetadata> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteAllGrpcConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
async fn cmd_delete_send_history<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteSendHistoryReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteAllHttpResponsesReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
|
||||
Ok(crate::cmd_new_child_window(ctx.window.clone(), &req.url, &req.label, &req.title, req.inner_size).await?)
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdNewChildWindowReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_new_child_window(
|
||||
ctx.window.clone(),
|
||||
&req.url,
|
||||
&req.label,
|
||||
&req.title,
|
||||
req.inner_size,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_new_main_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewMainWindowReq) -> Result<()> {
|
||||
async fn cmd_new_main_window<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdNewMainWindowReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_new_main_window(ctx.window.app_handle().clone(), &req.url).await?)
|
||||
}
|
||||
|
||||
async fn cmd_check_for_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdCheckForUpdatesReq) -> Result<bool> {
|
||||
Ok(crate::cmd_check_for_updates(ctx.window.clone(), ctx.window.app_handle().state::<Mutex<YaakUpdater>>()).await?)
|
||||
async fn cmd_check_for_updates<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdCheckForUpdatesReq,
|
||||
) -> Result<bool> {
|
||||
Ok(crate::cmd_check_for_updates(
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<YaakUpdater>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_decrypt_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
async fn cmd_decrypt_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDecryptTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
|
||||
async fn cmd_secure_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSecureTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
async fn cmd_get_themes<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetThemesReq,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
async fn cmd_enable_encryption<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdEnableEncryptionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
|
||||
async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdRevealWorkspaceKeyReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
|
||||
async fn cmd_set_workspace_key<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSetWorkspaceKeyReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
|
||||
async fn cmd_disable_encryption<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDisableEncryptionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_default_headers<R: Runtime>(ctx: ClientCtx<R>, req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
|
||||
async fn cmd_default_headers<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDefaultHeadersReq,
|
||||
) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -649,27 +845,45 @@ async fn models_delete<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDeleteReq) -> R
|
||||
Ok(deleted?)
|
||||
}
|
||||
|
||||
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
|
||||
async fn models_duplicate<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsDuplicateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
|
||||
async fn models_websocket_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
|
||||
async fn models_grpc_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGrpcEventsReq,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
async fn models_get_settings<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGetSettingsReq,
|
||||
) -> Result<Settings> {
|
||||
Ok(yaak_commands::models::models_get_settings(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_get_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
|
||||
async fn models_get_graphql_introspection<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGetGraphqlIntrospectionReq,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
|
||||
async fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -680,7 +894,10 @@ async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req:
|
||||
/// freezes the app"). Escape sequences sidestep it. This is a quirk of the
|
||||
/// webview transport, not of the data, so it lives in the adapter rather than
|
||||
/// the shared handler.
|
||||
async fn models_workspace_models<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWorkspaceModelsReq) -> Result<String> {
|
||||
async fn models_workspace_models<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsWorkspaceModelsReq,
|
||||
) -> Result<String> {
|
||||
let json = yaak_commands::models::models_workspace_models(ctx, req).await?;
|
||||
Ok(escape_str_for_webview(&json))
|
||||
}
|
||||
@@ -706,7 +923,10 @@ fn escape_str_for_webview(input: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
|
||||
async fn cmd_git_checkout<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitCheckoutReq,
|
||||
) -> Result<String> {
|
||||
Ok(crate::git_ext::cmd_git_checkout(&req.dir, &req.branch, req.force).await?)
|
||||
}
|
||||
|
||||
@@ -714,31 +934,52 @@ async fn cmd_git_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchReq) ->
|
||||
Ok(crate::git_ext::cmd_git_branch(&req.dir, &req.branch, req.base.as_deref()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_delete_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteBranchReq) -> Result<BranchDeleteResult> {
|
||||
async fn cmd_git_delete_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitDeleteBranchReq,
|
||||
) -> Result<BranchDeleteResult> {
|
||||
Ok(crate::git_ext::cmd_git_delete_branch(&req.dir, &req.branch, req.force).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_delete_remote_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteRemoteBranchReq) -> Result<()> {
|
||||
async fn cmd_git_delete_remote_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitDeleteRemoteBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_delete_remote_branch(&req.dir, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_merge_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitMergeBranchReq) -> Result<()> {
|
||||
async fn cmd_git_merge_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitMergeBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_merge_branch(&req.dir, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_rename_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRenameBranchReq) -> Result<()> {
|
||||
async fn cmd_git_rename_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRenameBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_rename_branch(&req.dir, &req.old_name, &req.new_name).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitStatusReq) -> Result<GitStatusSummary> {
|
||||
async fn cmd_git_status<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitStatusReq,
|
||||
) -> Result<GitStatusSummary> {
|
||||
Ok(crate::git_ext::cmd_git_status(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_branch_info<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchInfoReq) -> Result<GitBranchInfo> {
|
||||
async fn cmd_git_branch_info<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitBranchInfoReq,
|
||||
) -> Result<GitBranchInfo> {
|
||||
Ok(crate::git_ext::cmd_git_branch_info(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_worktree_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitWorktreeStatusReq) -> Result<GitWorktreeStatus> {
|
||||
async fn cmd_git_worktree_status<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitWorktreeStatusReq,
|
||||
) -> Result<GitWorktreeStatus> {
|
||||
Ok(crate::git_ext::cmd_git_worktree_status(&req.dir).await?)
|
||||
}
|
||||
|
||||
@@ -746,15 +987,25 @@ async fn cmd_git_log<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogReq) -> Resul
|
||||
Ok(crate::git_ext::cmd_git_log(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_log_for_file<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogForFileReq) -> Result<Vec<GitCommit>> {
|
||||
async fn cmd_git_log_for_file<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitLogForFileReq,
|
||||
) -> Result<Vec<GitCommit>> {
|
||||
Ok(crate::git_ext::cmd_git_log_for_file(&req.dir, req.rela_path).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_file_diff_for_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitFileDiffForCommitReq) -> Result<GitFileDiff> {
|
||||
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
|
||||
async fn cmd_git_file_diff_for_commit<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitFileDiffForCommitReq,
|
||||
) -> Result<GitFileDiff> {
|
||||
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_initialize<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitInitializeReq) -> Result<()> {
|
||||
async fn cmd_git_initialize<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitInitializeReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_initialize(&req.dir).await?)
|
||||
}
|
||||
|
||||
@@ -778,11 +1029,17 @@ async fn cmd_git_pull<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullReq) -> Res
|
||||
Ok(crate::git_ext::cmd_git_pull(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_pull_force_reset<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullForceResetReq) -> Result<PullResult> {
|
||||
async fn cmd_git_pull_force_reset<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitPullForceResetReq,
|
||||
) -> Result<PullResult> {
|
||||
Ok(crate::git_ext::cmd_git_pull_force_reset(&req.dir, &req.remote, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_pull_merge<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullMergeReq) -> Result<PullResult> {
|
||||
async fn cmd_git_pull_merge<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitPullMergeReq,
|
||||
) -> Result<PullResult> {
|
||||
Ok(crate::git_ext::cmd_git_pull_merge(&req.dir, &req.remote, &req.branch).await?)
|
||||
}
|
||||
|
||||
@@ -794,27 +1051,47 @@ async fn cmd_git_unstage<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitUnstageReq)
|
||||
Ok(crate::git_ext::cmd_git_unstage(&req.dir, req.rela_paths).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_reset_changes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitResetChangesReq) -> Result<()> {
|
||||
async fn cmd_git_reset_changes<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitResetChangesReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_reset_changes(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_restore_files<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFilesReq) -> Result<()> {
|
||||
async fn cmd_git_restore_files<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRestoreFilesReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_files(&req.dir, req.rela_paths).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_restore_file_from_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFileFromCommitReq) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
|
||||
async fn cmd_git_restore_file_from_commit<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRestoreFileFromCommitReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_add_credential<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddCredentialReq) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password).await?)
|
||||
async fn cmd_git_add_credential<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitAddCredentialReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_remotes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRemotesReq) -> Result<Vec<GitRemote>> {
|
||||
async fn cmd_git_remotes<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRemotesReq,
|
||||
) -> Result<Vec<GitRemote>> {
|
||||
Ok(crate::git_ext::cmd_git_remotes(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_add_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddRemoteReq) -> Result<GitRemote> {
|
||||
async fn cmd_git_add_remote<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitAddRemoteReq,
|
||||
) -> Result<GitRemote> {
|
||||
Ok(crate::git_ext::cmd_git_add_remote(&req.dir, &req.name, &req.url).await?)
|
||||
}
|
||||
|
||||
@@ -822,58 +1099,130 @@ async fn cmd_git_rm_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRmRemoteRe
|
||||
Ok(crate::git_ext::cmd_git_rm_remote(&req.dir, &req.name).await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_calculate<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncCalculateReq) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate(ctx.window.app_handle().clone(), &req.workspace_id, &req.sync_dir).await?)
|
||||
async fn cmd_sync_calculate<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSyncCalculateReq,
|
||||
) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate(
|
||||
ctx.window.app_handle().clone(),
|
||||
&req.workspace_id,
|
||||
&req.sync_dir,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_calculate_fs<R: Runtime>(_ctx: ClientCtx<R>, req: CmdSyncCalculateFsReq) -> Result<Vec<SyncOp>> {
|
||||
async fn cmd_sync_calculate_fs<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdSyncCalculateFsReq,
|
||||
) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate_fs(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_apply<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncApplyReq) -> Result<()> {
|
||||
Ok(crate::sync_ext::cmd_sync_apply(ctx.window.app_handle().clone(), req.sync_ops, &req.sync_dir, &req.workspace_id).await?)
|
||||
Ok(crate::sync_ext::cmd_sync_apply(
|
||||
ctx.window.app_handle().clone(),
|
||||
req.sync_ops,
|
||||
&req.sync_dir,
|
||||
&req.workspace_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_delete_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsDeleteConnectionsReq) -> Result<()> {
|
||||
async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsDeleteConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_send(&req.connection_id, req.environment_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_send<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsSendReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_send(
|
||||
&req.connection_id,
|
||||
req.environment_id.as_deref(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_close<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsCloseReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_close(&req.connection_id, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_close<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsCloseReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_close(
|
||||
&req.connection_id,
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_connect<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsConnectReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_connect(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
|
||||
async fn cmd_plugins_search<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsSearchReq,
|
||||
) -> Result<PluginSearchResponse> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_search(ctx.window.app_handle().clone(), &req.query).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_install<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallReq) -> Result<()> {
|
||||
async fn cmd_plugins_install<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsInstallReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install(ctx.window.clone(), &req.name, req.version).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_install_from_directory<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallFromDirectoryReq) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory).await?)
|
||||
async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsInstallFromDirectoryReq,
|
||||
) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_uninstall<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsUninstallReq) -> Result<Plugin> {
|
||||
async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsUninstallReq,
|
||||
) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginInitErrorsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
|
||||
async fn cmd_plugins_updates<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdPluginsUpdatesReq,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_updates(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
|
||||
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?)
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use tokio::sync::watch;
|
||||
use yaak_rpc_schema::WatchResult;
|
||||
use yaak_sync::error::Error::InvalidSyncDirectory;
|
||||
use yaak_sync::sync::{
|
||||
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
|
||||
get_fs_candidates,
|
||||
FsCandidate, SyncOp, apply_db_sync_ops, apply_fs_sync_ops, apply_sync_state_ops,
|
||||
compute_sync_ops, get_db_candidates, get_fs_candidates,
|
||||
};
|
||||
use yaak_sync::watch::{WatchEvent, watch_directory};
|
||||
|
||||
@@ -49,11 +49,14 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
|
||||
sync_dir: &Path,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
let db = app_handle.db();
|
||||
// Files first, so the write transaction never waits on the filesystem
|
||||
let pending = apply_fs_sync_ops(workspace_id, sync_dir, sync_ops)?;
|
||||
let blobs = app_handle.blob_manager();
|
||||
let sync_state_ops = apply_sync_ops(&db, &blobs, workspace_id, sync_dir, sync_ops)?;
|
||||
apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?;
|
||||
Ok(())
|
||||
app_handle.db_manager().with_tx(|tx| {
|
||||
let sync_state_ops = apply_db_sync_ops(tx, &blobs, workspace_id, sync_dir, pending)?;
|
||||
apply_sync_state_ops(tx, workspace_id, sync_dir, sync_state_ops)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_watch<R, F>(
|
||||
|
||||
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use url::Url;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
@@ -26,7 +27,6 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
@@ -43,18 +43,20 @@ pub async fn cmd_ws_send<R: Runtime>(
|
||||
{
|
||||
Ok(connection) => Ok(connection),
|
||||
Err(e) => {
|
||||
app_handle.db().upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: connection.request_id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Error,
|
||||
message: e.to_string().into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
app_handle.with_tx(|tx| {
|
||||
tx.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: connection.request_id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Error,
|
||||
message: e.to_string().into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(connection)
|
||||
}
|
||||
@@ -96,18 +98,20 @@ async fn send_websocket_message<R: Runtime>(
|
||||
let mut ws_manager = ws_manager.lock().await;
|
||||
ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?;
|
||||
|
||||
app_handle.db().upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Text,
|
||||
message: message.into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
app_handle.with_tx(|tx| {
|
||||
tx.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Text,
|
||||
message: message.into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(connection.clone())
|
||||
}
|
||||
@@ -118,14 +122,13 @@ pub async fn cmd_ws_close<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||
) -> Result<WebsocketConnection> {
|
||||
let connection = {
|
||||
let db = app_handle.db();
|
||||
let connection = db.get_websocket_connection(connection_id)?;
|
||||
db.upsert_websocket_connection(
|
||||
let connection = app_handle.with_tx(|tx| {
|
||||
let connection = tx.get_websocket_connection(connection_id)?;
|
||||
tx.upsert_websocket_connection(
|
||||
&WebsocketConnection { state: WebsocketConnectionState::Closing, ..connection },
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?
|
||||
};
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut ws_manager = ws_manager.lock().await;
|
||||
if let Err(e) = ws_manager.close(&connection.id).await {
|
||||
@@ -169,14 +172,16 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let connection = app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
let connection = app_handle.with_tx(|tx| {
|
||||
tx.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (mut url, url_parameters) = apply_path_placeholders(&request.url, &request.url_parameters);
|
||||
if !url.starts_with("ws://") && !url.starts_with("wss://") {
|
||||
@@ -187,14 +192,16 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
let mut url = match Url::parse(&url) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
return Ok(app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
error: Some(format!("Failed to parse URL {}", e.to_string())),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?);
|
||||
return Ok(app_handle.with_tx(|tx| {
|
||||
tx.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
error: Some(format!("Failed to parse URL {}", e.to_string())),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -321,28 +328,32 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
error: Some(e.to_string()),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?);
|
||||
return Ok(app_handle.with_tx(|tx| {
|
||||
tx.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
error: Some(e.to_string()),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
};
|
||||
|
||||
app_handle.db().upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Open,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
app_handle.with_tx(|tx| {
|
||||
tx.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Open,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let response_headers = response
|
||||
.headers()
|
||||
@@ -366,20 +377,22 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
if !set_cookie_headers.is_empty() {
|
||||
store.store_cookies_from_response(&convert_ws_url_to_http(&url), &set_cookie_headers);
|
||||
cookie_jar.cookies = store.get_all_cookies();
|
||||
app_handle.db().upsert_cookie_jar(cookie_jar, &UpdateSource::Background)?;
|
||||
app_handle.with_tx(|tx| tx.upsert_cookie_jar(cookie_jar, &UpdateSource::Background))?;
|
||||
}
|
||||
}
|
||||
|
||||
let connection = app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
state: WebsocketConnectionState::Connected,
|
||||
headers: response_headers,
|
||||
status: response.status().as_u16() as i32,
|
||||
url: request.url.clone(),
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
let connection = app_handle.with_tx(|tx| {
|
||||
tx.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
state: WebsocketConnectionState::Connected,
|
||||
headers: response_headers,
|
||||
status: response.status().as_u16() as i32,
|
||||
url: request.url.clone(),
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
{
|
||||
let connection_id = connection.id.clone();
|
||||
@@ -395,57 +408,60 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
}
|
||||
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
is_server: true,
|
||||
message_type: match message {
|
||||
Message::Text(_) => WebsocketEventType::Text,
|
||||
Message::Binary(_) => WebsocketEventType::Binary,
|
||||
Message::Ping(_) => WebsocketEventType::Ping,
|
||||
Message::Pong(_) => WebsocketEventType::Pong,
|
||||
Message::Close(_) => WebsocketEventType::Close,
|
||||
// Raw frame will never happen during a read
|
||||
Message::Frame(_) => WebsocketEventType::Frame,
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
is_server: true,
|
||||
message_type: match message {
|
||||
Message::Text(_) => WebsocketEventType::Text,
|
||||
Message::Binary(_) => WebsocketEventType::Binary,
|
||||
Message::Ping(_) => WebsocketEventType::Ping,
|
||||
Message::Pong(_) => WebsocketEventType::Pong,
|
||||
Message::Close(_) => WebsocketEventType::Close,
|
||||
// Raw frame will never happen during a read
|
||||
Message::Frame(_) => WebsocketEventType::Frame,
|
||||
},
|
||||
message: message.into_data().into(),
|
||||
..Default::default()
|
||||
},
|
||||
message: message.into_data().into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
info!("Websocket connection closed");
|
||||
if !has_written_close {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
is_server: true,
|
||||
message_type: WebsocketEventType::Close,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
is_server: true,
|
||||
message_type: WebsocketEventType::Close,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
@@ -453,7 +469,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
|
||||
/// Convert WS URL to HTTP URL for cookie filtering
|
||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||
fn convert_ws_url_to_http(ws_url: &Url) -> Url {
|
||||
|
||||
@@ -9,7 +9,7 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
|
||||
use ts_rs::TS;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_common::platform::get_os_str;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::client_db::{ClientDb, WriteDb};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
@@ -17,6 +17,10 @@ use yaak_models::util::UpdateSource;
|
||||
/// This is needed temporarily until all crates are refactored to not use Tauri.
|
||||
trait QueryManagerExt<'a, R> {
|
||||
fn db(&'a self) -> ClientDb<'a>;
|
||||
fn with_tx<T>(
|
||||
&'a self,
|
||||
func: impl FnOnce(&WriteDb) -> yaak_models::error::Result<T>,
|
||||
) -> yaak_models::error::Result<T>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
@@ -24,6 +28,14 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
let qm = self.state::<QueryManager>();
|
||||
qm.inner().connect()
|
||||
}
|
||||
|
||||
fn with_tx<T>(
|
||||
&'a self,
|
||||
func: impl FnOnce(&WriteDb) -> yaak_models::error::Result<T>,
|
||||
) -> yaak_models::error::Result<T> {
|
||||
let qm = self.state::<QueryManager>();
|
||||
qm.inner().with_tx(func)
|
||||
}
|
||||
}
|
||||
|
||||
const KV_NAMESPACE: &str = "license";
|
||||
@@ -137,12 +149,17 @@ pub async fn activate_license<R: Runtime>(
|
||||
}
|
||||
|
||||
let body: ActivateLicenseResponsePayload = response.json().await?;
|
||||
window.app_handle().db().set_key_value_str(
|
||||
KV_ACTIVATION_ID_KEY,
|
||||
KV_NAMESPACE,
|
||||
body.activation_id.as_str(),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
);
|
||||
if let Err(e) = window.app_handle().with_tx(|tx| {
|
||||
tx.set_key_value_str(
|
||||
KV_ACTIVATION_ID_KEY,
|
||||
KV_NAMESPACE,
|
||||
body.activation_id.as_str(),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
);
|
||||
Ok(())
|
||||
}) {
|
||||
warn!("Failed to store license activation: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = window.emit("license-activated", true) {
|
||||
warn!("Failed to emit check-license event: {}", e);
|
||||
@@ -172,11 +189,13 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
|
||||
return Err(ServerError);
|
||||
}
|
||||
|
||||
app_handle.db().delete_key_value(
|
||||
KV_ACTIVATION_ID_KEY,
|
||||
KV_NAMESPACE,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
app_handle.with_tx(|tx| {
|
||||
tx.delete_key_value(
|
||||
KV_ACTIVATION_ID_KEY,
|
||||
KV_NAMESPACE,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Err(e) = app_handle.emit("license-deactivated", true) {
|
||||
warn!("Failed to emit deactivate-license event: {}", e);
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
//! the second ask. Sharing the handle instead makes nested *reads* work the way
|
||||
//! they do on the desktop; nested *write transactions* fail on both, only
|
||||
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
|
||||
//! connection blocks on `busy_timeout` and then fails).
|
||||
//! call waits for the one writer connection, which the outer call holds, and
|
||||
//! times out).
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
|
||||
@@ -20,7 +20,9 @@ impl UpdateSource {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum ModelChangeEvent {
|
||||
Upsert { created: bool },
|
||||
Upsert {
|
||||
created: bool,
|
||||
},
|
||||
/// A delete for a workspace implies deletion of every model in that
|
||||
/// workspace — children are bulk-deleted without their own change rows or
|
||||
/// events, and consumers must prune the subtree themselves (the frontend
|
||||
|
||||
@@ -29,11 +29,10 @@ use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesRe
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
|
||||
GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
|
||||
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
|
||||
@@ -13,8 +13,9 @@ pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
|
||||
/// `dispatch` call frame, so it cannot borrow, and contexts are cheap clones
|
||||
/// (handles and `Arc`s). Synchronous handlers wrap into this via `rpc_handler!`
|
||||
/// with no visible change.
|
||||
type HandlerFn<Ctx> =
|
||||
Box<dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync>;
|
||||
type HandlerFn<Ctx> = Box<
|
||||
dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync,
|
||||
>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcError {
|
||||
@@ -249,9 +250,8 @@ macro_rules! rpc_handler_async {
|
||||
Box::new(|ctx, payload| {
|
||||
Box::pin(async move {
|
||||
let req = serde_json::from_value(payload).map_err($crate::RpcError::from)?;
|
||||
let res = $f(ctx, req)
|
||||
.await
|
||||
.map_err(|e| $crate::RpcError { message: e.to_string() })?;
|
||||
let res =
|
||||
$f(ctx, req).await.map_err(|e| $crate::RpcError { message: e.to_string() })?;
|
||||
serde_json::to_value(res).map_err($crate::RpcError::from)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ pub trait Host: Clone {
|
||||
self.query_manager().connect()
|
||||
}
|
||||
|
||||
fn blobs(&self) -> BlobContext {
|
||||
fn blobs(&self) -> BlobContext<'_> {
|
||||
self.blob_manager().connect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn models_upsert<H: Host>(host: H, req: ModelsUpsertReq) -> Result<String> {
|
||||
let db = host.db();
|
||||
let blobs = host.blob_manager();
|
||||
let source = host.update_source();
|
||||
Ok(yaak_models::models_ops::upsert_model(&db, blobs, req.model, &source)?)
|
||||
Ok(host
|
||||
.query_manager()
|
||||
.with_tx(|tx| yaak_models::models_ops::upsert_model(tx, blobs, req.model, &source))?)
|
||||
}
|
||||
|
||||
/// Deletes cascade — a workspace can hold thousands of requests — and run in a
|
||||
@@ -75,12 +76,9 @@ pub async fn models_upsert_graphql_introspection<H: Host>(
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
let source = host.update_source();
|
||||
Ok(host.db().upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
&source,
|
||||
)?)
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
tx.upsert_graphql_introspection(&req.workspace_id, &req.request_id, req.content, &source)
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Everything the frontend's model store needs to boot, as one JSON string.
|
||||
@@ -112,9 +110,16 @@ pub async fn models_workspace_models<H: PluginHost>(
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
// Opening a workspace is where the rows it is assumed to have get created
|
||||
host.query_manager().with_tx(|tx| {
|
||||
tx.ensure_base_environment(wid)?;
|
||||
tx.ensure_default_cookie_jar(wid)?;
|
||||
tx.ensure_workspace_meta(wid)?;
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})?;
|
||||
let db = host.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
@@ -132,23 +137,26 @@ pub async fn cmd_get_workspace_meta<H: Host>(
|
||||
host: H,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
let db = host.db();
|
||||
let workspace = db.get_workspace(&req.workspace_id)?;
|
||||
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
|
||||
let workspace = host.db().get_workspace(&req.workspace_id)?;
|
||||
Ok(host.query_manager().with_tx(|tx| tx.ensure_workspace_meta(&workspace.id))?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_all_grpc_connections<H: Host>(
|
||||
host: H,
|
||||
req: CmdDeleteAllGrpcConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(host.db().delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?)
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
tx.delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_all_http_responses<H: Host>(
|
||||
host: H,
|
||||
req: CmdDeleteAllHttpResponsesReq,
|
||||
) -> Result<()> {
|
||||
host.db().delete_all_http_responses_for_request(&req.request_id, &host.update_source())?;
|
||||
host.query_manager().with_tx(|tx| {
|
||||
tx.delete_all_http_responses_for_request(&req.request_id, &host.update_source())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -156,9 +164,9 @@ pub async fn cmd_ws_delete_connections<H: Host>(
|
||||
host: H,
|
||||
req: CmdWsDeleteConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(host
|
||||
.db()
|
||||
.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())?)
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
tx.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_send_history<H: Host>(host: H, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
|
||||
@@ -118,6 +118,7 @@ impl Host for TestHost {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn writes_carry_the_client_id() {
|
||||
let host = TestHost::new();
|
||||
host.drain_writes(); // the rows startup creates
|
||||
|
||||
let workspace = Workspace { name: "From a test".to_string(), ..Default::default() };
|
||||
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
|
||||
@@ -406,21 +407,23 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
// shared code; only the callback came from the host. Rendering a real
|
||||
// variable is what proves the chain was resolved rather than skipped.
|
||||
let environment = host
|
||||
.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: id.clone(),
|
||||
name: "Test env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "greeting".to_string(),
|
||||
value: "hello".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: id.clone(),
|
||||
name: "Test env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "greeting".to_string(),
|
||||
value: "hello".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
})
|
||||
.expect("seed environment");
|
||||
|
||||
let rendered = cmd_render_template(
|
||||
@@ -459,30 +462,33 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Auth".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace { name: "Auth".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
})
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "token".to_string(),
|
||||
value: "s3cret".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
host.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "token".to_string(),
|
||||
value: "s3cret".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
})
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
let environment = host.db().list_environments(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string()));
|
||||
@@ -521,30 +527,33 @@ async fn template_function_values_are_rendered_before_the_host_sees_them() {
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Functions".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace { name: "Functions".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
})
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "1PASSWORD_TOKEN".to_string(),
|
||||
value: "ops_abc123".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
host.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "1PASSWORD_TOKEN".to_string(),
|
||||
value: "ops_abc123".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
})
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
let environment = host.db().list_environments(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
|
||||
|
||||
@@ -83,7 +83,7 @@ impl EncryptionManager {
|
||||
|
||||
let workspace_meta = self.query_manager.with_tx::<WorkspaceMeta, Error>(|tx| {
|
||||
let workspace = tx.get_workspace(workspace_id)?;
|
||||
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
|
||||
let workspace_meta = tx.ensure_workspace_meta(workspace_id)?;
|
||||
tx.upsert_workspace(
|
||||
&Workspace { encryption_key_challenge, ..workspace },
|
||||
&UpdateSource::Background,
|
||||
@@ -103,7 +103,7 @@ impl EncryptionManager {
|
||||
|
||||
pub fn ensure_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
|
||||
let workspace_meta =
|
||||
self.query_manager.connect().get_or_create_workspace_meta(workspace_id)?;
|
||||
self.query_manager.with_tx(|tx| tx.ensure_workspace_meta(workspace_id))?;
|
||||
|
||||
// Already exists
|
||||
if let Some(_) = workspace_meta.encryption_key {
|
||||
@@ -120,7 +120,7 @@ impl EncryptionManager {
|
||||
|
||||
self.query_manager.with_tx::<(), Error>(|tx| {
|
||||
let workspace = tx.get_workspace(workspace_id)?;
|
||||
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
|
||||
let workspace_meta = tx.ensure_workspace_meta(workspace_id)?;
|
||||
|
||||
// Clear encryption challenge on workspace
|
||||
tx.upsert_workspace(
|
||||
@@ -152,10 +152,12 @@ impl EncryptionManager {
|
||||
}
|
||||
};
|
||||
|
||||
let db = self.query_manager.connect();
|
||||
let workspace_meta = db.get_or_create_workspace_meta(workspace_id)?;
|
||||
|
||||
let key = match workspace_meta.encryption_key {
|
||||
let key = match self
|
||||
.query_manager
|
||||
.connect()
|
||||
.get_workspace_meta(workspace_id)
|
||||
.and_then(|m| m.encryption_key)
|
||||
{
|
||||
None => return Err(MissingWorkspaceKey),
|
||||
Some(k) => k,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use log::info;
|
||||
use std::path::PathBuf;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::client_db::WriteDb;
|
||||
use yaak_models::error::Result;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
@@ -44,7 +44,8 @@ impl Host {
|
||||
}
|
||||
|
||||
/// Run once after the database is open, before the host answers anything.
|
||||
pub fn on_launch(host: &Host, db: &ClientDb, blobs: &BlobManager) -> Result<()> {
|
||||
/// Takes the write handle: a launch closes what the last session left open.
|
||||
pub fn on_launch(host: &Host, db: &WriteDb, blobs: &BlobManager) -> Result<()> {
|
||||
db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)?;
|
||||
|
||||
if host.role == Role::Owner {
|
||||
@@ -77,53 +78,48 @@ mod tests {
|
||||
#[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,
|
||||
)
|
||||
let pending = query_manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace { name: "Hooks".to_string(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
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();
|
||||
query_manager.with_tx(|db| on_launch(&Host::guest(), db, &blob_manager)).unwrap();
|
||||
let response = query_manager.connect().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();
|
||||
query_manager.with_tx(|db| on_launch(&Host::owner(), db, &blob_manager)).unwrap();
|
||||
let response = query_manager.connect().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();
|
||||
}
|
||||
blob_manager
|
||||
.with_tx(|b| b.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())))
|
||||
.unwrap();
|
||||
|
||||
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
|
||||
query_manager.with_tx(|db| on_launch(&Host::owner(), db, &blob_manager)).unwrap();
|
||||
|
||||
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::util::generate_prefixed_id;
|
||||
use include_dir::{Dir, include_dir};
|
||||
use log::{debug, info};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use yaak_database::{SqliteConn, SqlitePool};
|
||||
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use std::ops::Deref;
|
||||
use yaak_database::{ConnectionOrTx, SqlitePool};
|
||||
|
||||
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
|
||||
|
||||
@@ -22,41 +24,64 @@ impl BodyChunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the blob database connection pool.
|
||||
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
|
||||
/// Manages the blob database: a reader pool and a single writer, for the
|
||||
/// same reason as [`crate::query_manager::QueryManager`].
|
||||
// Pools are internally synchronized — don't wrap them in a Mutex. A Mutex held across the
|
||||
// blocking `get()` serializes every blob access behind the slowest waiter, freezing the
|
||||
// whole app whenever the pool is exhausted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlobManager {
|
||||
pool: SqlitePool,
|
||||
readers: SqlitePool,
|
||||
writer: SqlitePool,
|
||||
}
|
||||
|
||||
impl BlobManager {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
/// `writer` must be a pool with a single connection.
|
||||
pub fn new(readers: SqlitePool, writer: SqlitePool) -> Self {
|
||||
Self { readers, writer }
|
||||
}
|
||||
|
||||
pub fn connect(&self) -> BlobContext {
|
||||
let conn = self.pool.get().expect("Failed to get blob DB connection from pool");
|
||||
BlobContext { conn }
|
||||
/// A read handle from the reader pool.
|
||||
pub fn connect(&self) -> BlobContext<'_> {
|
||||
let conn = self.readers.get().expect("Failed to get blob DB connection from pool");
|
||||
BlobContext { conn: ConnectionOrTx::Connection(conn) }
|
||||
}
|
||||
|
||||
/// Run `func` in a transaction on the writer connection.
|
||||
pub fn with_tx<T, E>(
|
||||
&self,
|
||||
func: impl FnOnce(&BlobWriter) -> std::result::Result<T, E>,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
E: From<crate::error::Error>,
|
||||
{
|
||||
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
|
||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||
.map_err(crate::error::Error::SqlError)?;
|
||||
let writer = BlobWriter { ctx: BlobContext { conn: ConnectionOrTx::Transaction(&tx) } };
|
||||
match func(&writer) {
|
||||
Ok(val) => {
|
||||
tx.commit().map_err(|e| {
|
||||
GenericError(format!("Failed to commit blob transaction {e:?}"))
|
||||
})?;
|
||||
Ok(val)
|
||||
}
|
||||
Err(e) => {
|
||||
tx.rollback().map_err(|e| {
|
||||
GenericError(format!("Failed to rollback blob transaction {e:?}"))
|
||||
})?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for blob database operations.
|
||||
pub struct BlobContext {
|
||||
conn: SqliteConn,
|
||||
/// Read handle for the blob database.
|
||||
pub struct BlobContext<'a> {
|
||||
conn: ConnectionOrTx<'a>,
|
||||
}
|
||||
|
||||
impl BlobContext {
|
||||
/// Insert a single chunk.
|
||||
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<'a> BlobContext<'a> {
|
||||
/// Get all chunks for a body, ordered by chunk_index.
|
||||
pub fn get_chunks(&self, body_id: &str) -> Result<Vec<BodyChunk>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
@@ -87,25 +112,11 @@ impl BlobContext {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Delete all chunks for a body.
|
||||
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
|
||||
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all bodies for a response).
|
||||
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get total size of a body without loading data.
|
||||
impl BlobContext {
|
||||
/// Get total size of a body without loading data.
|
||||
pub fn get_body_size(&self, body_id: &str) -> Result<usize> {
|
||||
let size: i64 = self
|
||||
.conn
|
||||
.resolve()
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
@@ -119,6 +130,7 @@ impl BlobContext {
|
||||
pub fn body_exists(&self, body_id: &str) -> Result<bool> {
|
||||
let count: i64 = self
|
||||
.conn
|
||||
.resolve()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
@@ -129,6 +141,44 @@ impl BlobContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write handle for the blob database. Derefs to [`BlobContext`] for reads.
|
||||
pub struct BlobWriter<'a> {
|
||||
ctx: BlobContext<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for BlobWriter<'a> {
|
||||
type Target = BlobContext<'a>;
|
||||
|
||||
fn deref(&self) -> &BlobContext<'a> {
|
||||
&self.ctx
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BlobWriter<'a> {
|
||||
/// Insert a single chunk.
|
||||
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks for a body.
|
||||
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
|
||||
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all
|
||||
/// bodies for a response).
|
||||
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run migrations for the blob database.
|
||||
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
info!("Running blob database migrations");
|
||||
@@ -196,28 +246,39 @@ pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
|
||||
fn create_test_pool() -> SqlitePool {
|
||||
fn create_test_manager() -> BlobManager {
|
||||
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
|
||||
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
|
||||
migrate_blob_db(&pool).unwrap();
|
||||
pool
|
||||
BlobManager::new(pool.clone(), pool)
|
||||
}
|
||||
|
||||
fn insert(manager: &BlobManager, chunks: &[BodyChunk]) {
|
||||
manager
|
||||
.with_tx(|b| {
|
||||
for c in chunks {
|
||||
b.insert_chunk(c)?;
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_get_chunks() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec());
|
||||
let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec());
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 0, b"Hello, ".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"World!".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
ctx.insert_chunk(&chunk1).unwrap();
|
||||
ctx.insert_chunk(&chunk2).unwrap();
|
||||
|
||||
let chunks = ctx.get_chunks(body_id).unwrap();
|
||||
let chunks = manager.connect().get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert_eq!(chunks[0].chunk_index, 0);
|
||||
assert_eq!(chunks[0].data, b"Hello, ");
|
||||
@@ -227,18 +288,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_chunks_ordered_by_index() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
|
||||
// Insert out of order
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 2, b"C".to_vec()),
|
||||
BodyChunk::new(body_id, 0, b"A".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"B".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let chunks = ctx.get_chunks(body_id).unwrap();
|
||||
let chunks = manager.connect().get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 3);
|
||||
assert_eq!(chunks[0].data, b"A");
|
||||
assert_eq!(chunks[1].data, b"B");
|
||||
@@ -247,89 +309,85 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_delete_chunks() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap();
|
||||
insert(&manager, &[BodyChunk::new(body_id, 0, b"data".to_vec())]);
|
||||
assert!(manager.connect().body_exists(body_id).unwrap());
|
||||
|
||||
assert!(ctx.body_exists(body_id).unwrap());
|
||||
|
||||
ctx.delete_chunks(body_id).unwrap();
|
||||
manager.with_tx(|b| b.delete_chunks(body_id)).unwrap();
|
||||
|
||||
let ctx = manager.connect();
|
||||
assert!(!ctx.body_exists(body_id).unwrap());
|
||||
assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_chunks_like() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
// Insert chunks for same response but different body types
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new("rs_abc.request", 0, b"req".to_vec()),
|
||||
BodyChunk::new("rs_abc.response", 0, b"resp".to_vec()),
|
||||
BodyChunk::new("rs_other.request", 0, b"other".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
// Delete all bodies for rs_abc
|
||||
ctx.delete_chunks_like("rs_abc.%").unwrap();
|
||||
manager.with_tx(|b| b.delete_chunks_like("rs_abc.%")).unwrap();
|
||||
|
||||
// rs_abc bodies should be gone
|
||||
let ctx = manager.connect();
|
||||
assert!(!ctx.body_exists("rs_abc.request").unwrap());
|
||||
assert!(!ctx.body_exists("rs_abc.response").unwrap());
|
||||
|
||||
// rs_other should still exist
|
||||
assert!(ctx.body_exists("rs_other.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_body_size() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
let body_id = "rs_test123.request";
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new(body_id, 0, b"Hello".to_vec()),
|
||||
BodyChunk::new(body_id, 1, b"World".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let size = ctx.get_body_size(body_id).unwrap();
|
||||
let size = manager.connect().get_body_size(body_id).unwrap();
|
||||
assert_eq!(size, 10); // "Hello" + "World" = 10 bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_body_size_empty() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let size = ctx.get_body_size("nonexistent").unwrap();
|
||||
let manager = create_test_manager();
|
||||
let size = manager.connect().get_body_size("nonexistent").unwrap();
|
||||
assert_eq!(size, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_exists() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
let manager = create_test_manager();
|
||||
assert!(!manager.connect().body_exists("rs_test.request").unwrap());
|
||||
|
||||
assert!(!ctx.body_exists("rs_test.request").unwrap());
|
||||
insert(&manager, &[BodyChunk::new("rs_test.request", 0, b"data".to_vec())]);
|
||||
|
||||
ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap();
|
||||
|
||||
assert!(ctx.body_exists("rs_test.request").unwrap());
|
||||
assert!(manager.connect().body_exists("rs_test.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_bodies_isolated() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let manager = create_test_manager();
|
||||
insert(
|
||||
&manager,
|
||||
&[
|
||||
BodyChunk::new("body1", 0, b"data1".to_vec()),
|
||||
BodyChunk::new("body2", 0, b"data2".to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let ctx = manager.connect();
|
||||
|
||||
ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap();
|
||||
ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap();
|
||||
|
||||
let chunks1 = ctx.get_chunks("body1").unwrap();
|
||||
let chunks2 = ctx.get_chunks("body2").unwrap();
|
||||
|
||||
@@ -341,16 +399,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_large_chunk() {
|
||||
let pool = create_test_pool();
|
||||
let manager = BlobManager::new(pool);
|
||||
let ctx = manager.connect();
|
||||
|
||||
let manager = create_test_manager();
|
||||
// 1MB chunk
|
||||
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
|
||||
let body_id = "rs_large.request";
|
||||
insert(&manager, &[BodyChunk::new(body_id, 0, large_data.clone())]);
|
||||
|
||||
ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap();
|
||||
|
||||
let ctx = manager.connect();
|
||||
let chunks = ctx.get_chunks(body_id).unwrap();
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].data, large_data);
|
||||
|
||||
@@ -3,18 +3,25 @@ use crate::models::{AnyModel, UpsertModelInfo};
|
||||
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
|
||||
use rusqlite::params;
|
||||
use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::DbContext;
|
||||
|
||||
/// A read handle. Comes from the reader pool and can only query.
|
||||
///
|
||||
/// Anything that changes a row lives on [`WriteDb`], which is only ever handed
|
||||
/// out inside a transaction on the single writer connection. That split is
|
||||
/// what keeps the pool from filling with writers waiting on each other: there
|
||||
/// is one writer, so there is never a second one to wait for.
|
||||
pub struct ClientDb<'a> {
|
||||
pub(crate) ctx: DbContext<'a>,
|
||||
pub(crate) events_tx: mpsc::Sender<ModelPayload>,
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
Self { ctx, events_tx }
|
||||
pub fn new(ctx: DbContext<'a>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Access the underlying connection for custom queries.
|
||||
@@ -22,8 +29,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.ctx.conn()
|
||||
}
|
||||
|
||||
// --- Read delegates (thin wrappers over DbContext) ---
|
||||
|
||||
pub(crate) fn find_one<M>(
|
||||
&self,
|
||||
col: impl IntoColumnRef + IntoIden + Clone,
|
||||
@@ -64,6 +69,39 @@ impl<'a> ClientDb<'a> {
|
||||
{
|
||||
Ok(self.ctx.find_many(col, value, limit)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A write handle: a [`ClientDb`] on the writer connection, inside a
|
||||
/// transaction, that can also change rows. Derefs to [`ClientDb`] so every
|
||||
/// query is available while writing, and reads inside the transaction see
|
||||
/// its own uncommitted writes.
|
||||
///
|
||||
/// Model events are held back until the transaction commits; a rollback
|
||||
/// discards them along with the rows.
|
||||
pub struct WriteDb<'a> {
|
||||
db: ClientDb<'a>,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
pending_events: RefCell<Vec<ModelPayload>>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for WriteDb<'a> {
|
||||
type Target = ClientDb<'a>;
|
||||
|
||||
fn deref(&self) -> &ClientDb<'a> {
|
||||
&self.db
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
Self { db: ClientDb::new(ctx), events_tx, pending_events: RefCell::new(Vec::new()) }
|
||||
}
|
||||
|
||||
/// The events for everything written so far, to send once the
|
||||
/// transaction has committed.
|
||||
pub(crate) fn into_events(self) -> Vec<ModelPayload> {
|
||||
self.pending_events.into_inner()
|
||||
}
|
||||
|
||||
/// Bulk-delete all rows matching a column value WITHOUT recording model
|
||||
/// changes or emitting events. Only use for cascades whose deletion is
|
||||
@@ -80,8 +118,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(self.ctx.delete_many::<M>(col, value)?)
|
||||
}
|
||||
|
||||
// --- Write operations (with event recording) ---
|
||||
|
||||
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
|
||||
where
|
||||
M: Into<AnyModel> + UpsertModelInfo + Clone,
|
||||
@@ -95,7 +131,7 @@ impl<'a> ClientDb<'a> {
|
||||
};
|
||||
|
||||
self.record_model_change(&payload)?;
|
||||
let _ = self.events_tx.send(payload);
|
||||
self.pending_events.borrow_mut().push(payload);
|
||||
|
||||
Ok(m)
|
||||
}
|
||||
@@ -113,7 +149,7 @@ impl<'a> ClientDb<'a> {
|
||||
};
|
||||
|
||||
self.record_model_change(&payload)?;
|
||||
let _ = self.events_tx.send(payload);
|
||||
self.pending_events.borrow_mut().push(payload);
|
||||
|
||||
Ok(m.clone())
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use yaak_database::SqlitePool;
|
||||
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
pub mod cookies;
|
||||
mod connection_or_tx;
|
||||
pub mod cookies;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod models;
|
||||
@@ -64,6 +64,17 @@ mod open {
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
|
||||
/// `(readers, writer)` over one file: a pool of `max_size` readers and a
|
||||
/// pool of exactly one writer.
|
||||
pub fn file_pools(
|
||||
path: impl Into<PathBuf>,
|
||||
max_size: u32,
|
||||
min_idle: u32,
|
||||
) -> Result<(SqlitePool, SqlitePool)> {
|
||||
let path: PathBuf = path.into();
|
||||
Ok((file_pool(&path, max_size, min_idle)?, file_pool(&path, 1, 1)?))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
|
||||
// In-memory DB doesn't support multiple connections
|
||||
@@ -90,6 +101,16 @@ mod open {
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
|
||||
/// One connection is all a browser VFS allows, so it reads and writes.
|
||||
pub fn file_pools(
|
||||
path: impl Into<PathBuf>,
|
||||
max_size: u32,
|
||||
min_idle: u32,
|
||||
) -> Result<(SqlitePool, SqlitePool)> {
|
||||
let pool = file_pool(path, max_size, min_idle)?;
|
||||
Ok((pool.clone(), pool))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
init_connection(&conn)?;
|
||||
@@ -108,27 +129,33 @@ pub fn init_standalone(
|
||||
let db_path = db_path.as_ref();
|
||||
let blob_path = blob_path.as_ref();
|
||||
|
||||
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
|
||||
// features — connections are held per-statement, so even heavy fan-out (e.g. many
|
||||
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
|
||||
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
|
||||
// Each database gets a reader pool and a one-connection writer pool; see
|
||||
// `QueryManager` for why. Reader pools are sized for concurrent in-flight
|
||||
// queries, not concurrent app features — connections are held per-statement,
|
||||
// so even heavy fan-out (e.g. many gRPC streams) only needs a handful at once.
|
||||
// Keep them modest: WAL connections hold ~3 file descriptors each, and macOS
|
||||
// GUI apps get a 256 fd soft limit.
|
||||
info!("Initializing app database {db_path:?}");
|
||||
let pool = open::file_pool(db_path, 20, 2)?;
|
||||
migrate_db(&pool)?;
|
||||
let (readers, writer) = open::file_pools(db_path, 20, 2)?;
|
||||
migrate_db(&writer)?;
|
||||
|
||||
info!("Initializing blobs database {blob_path:?}");
|
||||
let blob_pool = open::file_pool(blob_path, 10, 1)?;
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
let (blob_readers, blob_writer) = open::file_pools(blob_path, 10, 1)?;
|
||||
migrate_blob_db(&blob_writer)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let query_manager = QueryManager::new(pool, tx);
|
||||
let blob_manager = BlobManager::new(blob_pool);
|
||||
let query_manager = QueryManager::new(readers, writer, tx);
|
||||
let blob_manager = BlobManager::new(blob_readers, blob_writer);
|
||||
bootstrap(&query_manager)?;
|
||||
|
||||
Ok((query_manager, blob_manager, rx))
|
||||
}
|
||||
|
||||
/// Initialize the database managers with in-memory SQLite databases.
|
||||
/// Useful for testing and CI environments.
|
||||
///
|
||||
/// An in-memory database is private to its connection, so the one connection
|
||||
/// is both the reader pool and the writer.
|
||||
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
|
||||
let pool = open::memory_pool()?;
|
||||
migrate_db(&pool)?;
|
||||
@@ -137,8 +164,18 @@ pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<Mod
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let query_manager = QueryManager::new(pool, tx);
|
||||
let blob_manager = BlobManager::new(blob_pool);
|
||||
let query_manager = QueryManager::new(pool.clone(), pool, tx);
|
||||
let blob_manager = BlobManager::new(blob_pool.clone(), blob_pool);
|
||||
bootstrap(&query_manager)?;
|
||||
|
||||
Ok((query_manager, blob_manager, rx))
|
||||
}
|
||||
|
||||
/// The rows every client assumes exist: settings and at least one workspace.
|
||||
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
|
||||
query_manager.with_tx(|tx| {
|
||||
tx.ensure_settings()?;
|
||||
tx.ensure_default_workspace()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -8,14 +8,14 @@
|
||||
//! caller is a desktop window or an HTTP request.
|
||||
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::WriteDb;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::models::AnyModel;
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
pub fn upsert_model(
|
||||
db: &ClientDb,
|
||||
db: &WriteDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
@@ -41,7 +41,7 @@ pub fn upsert_model(
|
||||
|
||||
/// Deletes cascade, so callers run this inside a transaction.
|
||||
pub fn delete_model(
|
||||
tx: &ClientDb,
|
||||
tx: &WriteDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
@@ -69,7 +69,7 @@ pub fn delete_model(
|
||||
/// The model is re-read from the database rather than taken from the caller, so
|
||||
/// a duplicate never comes from a stale frontend snapshot.
|
||||
pub fn duplicate_model(
|
||||
tx: &ClientDb,
|
||||
tx: &WriteDb,
|
||||
model_type: &str,
|
||||
model_id: &str,
|
||||
source: &UpdateSource,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::WriteDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};
|
||||
use crate::util::{BatchUpsertResult, UpdateSource};
|
||||
use log::info;
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn batch_upsert(
|
||||
&self,
|
||||
workspaces: Vec<Workspace>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{CookieJar, CookieJarIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -9,18 +9,22 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn list_cookie_jars(&self, workspace_id: &str) -> Result<Vec<CookieJar>> {
|
||||
let mut cookie_jars = self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)?;
|
||||
self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
if cookie_jars.is_empty() {
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// A workspace with no cookie jar gets a default one.
|
||||
pub fn ensure_default_cookie_jar(&self, workspace_id: &str) -> Result<()> {
|
||||
if self.list_cookie_jars(workspace_id)?.is_empty() {
|
||||
let jar = CookieJar {
|
||||
name: "Default".to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
cookie_jars.push(self.upsert_cookie_jar(&jar, &UpdateSource::Background)?);
|
||||
self.upsert_cookie_jar(&jar, &UpdateSource::Background)?;
|
||||
}
|
||||
|
||||
Ok(cookie_jars)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_cookie_jar(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::conflict_free_name;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
|
||||
use crate::error::Result;
|
||||
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
|
||||
@@ -20,7 +20,7 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
|
||||
let environments = self.list_environments_ensure_base(workspace_id)?;
|
||||
let environments = self.list_environments(workspace_id)?;
|
||||
let base_environments = environments
|
||||
.into_iter()
|
||||
.filter(|e| e.parent_model == "workspace")
|
||||
@@ -30,38 +30,96 @@ impl<'a> ClientDb<'a> {
|
||||
return Err(MultipleBaseEnvironments(workspace_id.to_string()));
|
||||
}
|
||||
|
||||
Ok(base_environments.first().cloned().ok_or(
|
||||
// Should never happen because one should be created above if it does not exist
|
||||
MissingBaseEnvironment(workspace_id.to_string()),
|
||||
)?)
|
||||
Ok(base_environments
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or(MissingBaseEnvironment(workspace_id.to_string()))?)
|
||||
}
|
||||
|
||||
/// Lists environments and will create a base environment if one doesn't exist
|
||||
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
||||
let mut environments = self.list_environments_dangerous(workspace_id)?;
|
||||
pub fn list_environments(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
||||
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
|
||||
}
|
||||
|
||||
let base_environment = environments.iter().find(|e| e.parent_model == "workspace");
|
||||
/// Find other environments with the same parent folder
|
||||
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
|
||||
if environment.parent_model != "folder" {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if let None = base_environment {
|
||||
let e = self.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
name: "Global Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
self.list_environments(&environment.workspace_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.id != environment.id
|
||||
&& e.parent_model == "folder"
|
||||
&& e.parent_id == environment.parent_id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn resolve_environments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
folder_id: Option<&str>,
|
||||
active_environment_id: Option<&str>,
|
||||
) -> Result<Vec<Environment>> {
|
||||
let mut environments = Vec::new();
|
||||
|
||||
if let Some(folder_id) = folder_id {
|
||||
let folder = self.get_folder(folder_id)?;
|
||||
|
||||
// Add current folder's environment
|
||||
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
|
||||
environments.push(e);
|
||||
};
|
||||
|
||||
// Recurse up
|
||||
let ancestors = self.resolve_environments(
|
||||
workspace_id,
|
||||
folder.folder_id.as_deref(),
|
||||
active_environment_id,
|
||||
)?;
|
||||
info!("Created base environment {} for {workspace_id}", e.id);
|
||||
environments.push(e);
|
||||
environments.extend(ancestors);
|
||||
} else {
|
||||
// Add active and base environments
|
||||
if let Some(id) = active_environment_id {
|
||||
if let Ok(e) = self.get_environment(&id) {
|
||||
// Add active sub environment
|
||||
environments.push(e);
|
||||
};
|
||||
};
|
||||
|
||||
// Add the base environment. A workspace that has never been
|
||||
// opened has none yet; it simply contributes no variables.
|
||||
match self.get_base_environment(workspace_id) {
|
||||
Ok(e) => environments.push(e),
|
||||
Err(MissingBaseEnvironment(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(environments)
|
||||
}
|
||||
}
|
||||
|
||||
/// List environments for a workspace. Prefer list_environments_ensure_base()
|
||||
fn list_environments_dangerous(&self, workspace_id: &str) -> Result<Vec<Environment>> {
|
||||
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// The workspace's base environment, created if it does not exist.
|
||||
pub fn ensure_base_environment(&self, workspace_id: &str) -> Result<Environment> {
|
||||
match self.get_base_environment(workspace_id) {
|
||||
Err(MissingBaseEnvironment(_)) => {}
|
||||
other => return other,
|
||||
}
|
||||
let e = self.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
name: "Global Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
info!("Created base environment {} for {workspace_id}", e.id);
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
pub fn delete_environment(
|
||||
@@ -72,7 +130,7 @@ impl<'a> ClientDb<'a> {
|
||||
let deleted_environment = self.delete(environment, source)?;
|
||||
|
||||
// Recreate the base environment if we happened to delete it
|
||||
self.list_environments_ensure_base(&environment.workspace_id)?;
|
||||
self.ensure_base_environment(&environment.workspace_id)?;
|
||||
|
||||
Ok(deleted_environment)
|
||||
}
|
||||
@@ -90,7 +148,7 @@ impl<'a> ClientDb<'a> {
|
||||
let mut environment = environment.clone();
|
||||
environment.id = "".to_string();
|
||||
let sibling_names = self
|
||||
.list_environments_dangerous(&environment.workspace_id)?
|
||||
.list_environments(&environment.workspace_id)?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -98,23 +156,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.upsert_environment(&environment, source)
|
||||
}
|
||||
|
||||
/// Find other environments with the same parent folder
|
||||
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
|
||||
if environment.parent_model != "folder" {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.list_environments_dangerous(&environment.workspace_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.id != environment.id
|
||||
&& e.parent_model == "folder"
|
||||
&& e.parent_id == environment.parent_id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn upsert_environment(
|
||||
&self,
|
||||
environment: &Environment,
|
||||
@@ -154,43 +195,4 @@ impl<'a> ClientDb<'a> {
|
||||
source,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_environments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
folder_id: Option<&str>,
|
||||
active_environment_id: Option<&str>,
|
||||
) -> Result<Vec<Environment>> {
|
||||
let mut environments = Vec::new();
|
||||
|
||||
if let Some(folder_id) = folder_id {
|
||||
let folder = self.get_folder(folder_id)?;
|
||||
|
||||
// Add current folder's environment
|
||||
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
|
||||
environments.push(e);
|
||||
};
|
||||
|
||||
// Recurse up
|
||||
let ancestors = self.resolve_environments(
|
||||
workspace_id,
|
||||
folder.folder_id.as_deref(),
|
||||
active_environment_id,
|
||||
)?;
|
||||
environments.extend(ancestors);
|
||||
} else {
|
||||
// Add active and base environments
|
||||
if let Some(id) = active_environment_id {
|
||||
if let Ok(e) = self.get_environment(&id) {
|
||||
// Add active sub environment
|
||||
environments.push(e);
|
||||
};
|
||||
};
|
||||
|
||||
// Add the base environment
|
||||
environments.push(self.get_base_environment(workspace_id)?);
|
||||
}
|
||||
|
||||
Ok(environments)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::connection_or_tx::ConnectionOrTx;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
@@ -20,99 +20,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_many(FolderIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
match self.conn() {
|
||||
ConnectionOrTx::Connection(_) => {}
|
||||
ConnectionOrTx::Transaction(_) => {}
|
||||
}
|
||||
|
||||
let fid = &folder.id;
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.delete_http_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.delete_grpc_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.delete_websocket_request(&m, source)?;
|
||||
}
|
||||
|
||||
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.delete_environment(&e, source)?;
|
||||
}
|
||||
|
||||
// Recurse down into child folders
|
||||
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
self.delete_folder(&folder, source)?;
|
||||
}
|
||||
|
||||
self.delete(folder, source)
|
||||
}
|
||||
|
||||
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
|
||||
let folder = self.get_folder(id)?;
|
||||
self.delete_folder(&folder, source)
|
||||
}
|
||||
|
||||
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
self.upsert(folder, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
let fid = &src_folder.id;
|
||||
|
||||
let mut folder = Folder {
|
||||
id: "".into(),
|
||||
sort_priority: src_folder.sort_priority + 0.001,
|
||||
..src_folder.clone()
|
||||
};
|
||||
let sibling_names = self
|
||||
.list_folders(&folder.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|f| f.folder_id == folder.folder_id)
|
||||
.map(|f| f.name)
|
||||
.collect::<Vec<_>>();
|
||||
folder.name = conflict_free_name(&folder.name, &sibling_names);
|
||||
let new_folder = self.upsert_folder(&folder, source)?;
|
||||
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_http_request(
|
||||
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_websocket_request(
|
||||
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_grpc_request(
|
||||
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.upsert_environment(
|
||||
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
// Recurse down
|
||||
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
|
||||
}
|
||||
|
||||
Ok(new_folder)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_folder(
|
||||
&self,
|
||||
folder: &Folder,
|
||||
@@ -219,3 +126,98 @@ impl<'a> ClientDb<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
match self.conn() {
|
||||
ConnectionOrTx::Connection(_) => {}
|
||||
ConnectionOrTx::Transaction(_) => {}
|
||||
}
|
||||
|
||||
let fid = &folder.id;
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.delete_http_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.delete_grpc_request(&m, source)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.delete_websocket_request(&m, source)?;
|
||||
}
|
||||
|
||||
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.delete_environment(&e, source)?;
|
||||
}
|
||||
|
||||
// Recurse down into child folders
|
||||
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
self.delete_folder(&folder, source)?;
|
||||
}
|
||||
|
||||
self.delete(folder, source)
|
||||
}
|
||||
|
||||
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
|
||||
let folder = self.get_folder(id)?;
|
||||
self.delete_folder(&folder, source)
|
||||
}
|
||||
|
||||
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
self.upsert(folder, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
let fid = &src_folder.id;
|
||||
|
||||
let mut folder = Folder {
|
||||
id: "".into(),
|
||||
sort_priority: src_folder.sort_priority + 0.001,
|
||||
..src_folder.clone()
|
||||
};
|
||||
let sibling_names = self
|
||||
.list_folders(&folder.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|f| f.folder_id == folder.folder_id)
|
||||
.map(|f| f.name)
|
||||
.collect::<Vec<_>>();
|
||||
folder.name = conflict_free_name(&folder.name, &sibling_names);
|
||||
let new_folder = self.upsert_folder(&folder, source)?;
|
||||
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_http_request(
|
||||
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_websocket_request(
|
||||
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_grpc_request(
|
||||
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
|
||||
self.upsert_environment(
|
||||
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
|
||||
source,
|
||||
)?;
|
||||
}
|
||||
|
||||
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
|
||||
// Recurse down
|
||||
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
|
||||
}
|
||||
|
||||
Ok(new_folder)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn get_graphql_introspection(&self, request_id: &str) -> Option<GraphQlIntrospection> {
|
||||
self.find_optional(GraphQlIntrospectionIden::RequestId, request_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_graphql_introspection(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
@@ -13,6 +13,20 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_one(GrpcConnectionIden::Id, id)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
limit: Option<u64>,
|
||||
) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_all_grpc_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
@@ -53,18 +67,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.delete_grpc_connection(&grpc_connection, source)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
limit: Option<u64>,
|
||||
) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
|
||||
}
|
||||
|
||||
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
|
||||
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn cancel_pending_grpc_connections(&self) -> Result<()> {
|
||||
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
|
||||
let (sql, params) = Query::update()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcEvent, GrpcEventIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_grpc_events(&self, connection_id: &str) -> Result<Vec<GrpcEvent>> {
|
||||
self.find_many(GrpcEventIden::ConnectionId, connection_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_grpc_event(
|
||||
&self,
|
||||
grpc_event: &GrpcEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader,
|
||||
@@ -32,50 +32,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
pub fn delete_grpc_request(
|
||||
&self,
|
||||
m: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_grpc_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let request = self.get_grpc_request(id)?;
|
||||
self.delete_grpc_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let mut request = grpc_request.clone();
|
||||
request.id = "".to_string();
|
||||
request.sort_priority = request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_grpc_requests(&request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
request.name = conflict_free_name(&request.name, &sibling_names);
|
||||
self.upsert(&request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.upsert(grpc_request, source)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
@@ -146,3 +102,49 @@ impl<'a> ClientDb<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_grpc_request(
|
||||
&self,
|
||||
m: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_grpc_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let request = self.get_grpc_request(id)?;
|
||||
self.delete_grpc_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
let mut request = grpc_request.clone();
|
||||
request.id = "".to_string();
|
||||
request.sort_priority = request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_grpc_requests(&request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
request.name = conflict_free_name(&request.name, &sibling_names);
|
||||
self.upsert(&request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_grpc_request(
|
||||
&self,
|
||||
grpc_request: &GrpcRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.upsert(grpc_request, source)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||
@@ -18,50 +18,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_many(HttpRequestIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn delete_http_request(
|
||||
&self,
|
||||
m: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_http_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let http_request = self.get_http_request(id)?;
|
||||
self.delete_http_request(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let mut http_request = http_request.clone();
|
||||
http_request.id = "".to_string();
|
||||
http_request.sort_priority = http_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_http_requests(&http_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == http_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
|
||||
self.upsert(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.upsert(http_request, source)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
@@ -179,6 +135,52 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_http_request(
|
||||
&self,
|
||||
m: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
pub fn delete_http_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let http_request = self.get_http_request(id)?;
|
||||
self.delete_http_request(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
let mut http_request = http_request.clone();
|
||||
http_request.id = "".to_string();
|
||||
http_request.sort_priority = http_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_http_requests(&http_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == http_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
|
||||
self.upsert(&http_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_http_request(
|
||||
&self,
|
||||
http_request: &HttpRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.upsert(http_request, source)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::init_in_memory;
|
||||
@@ -225,72 +227,83 @@ mod tests {
|
||||
#[test]
|
||||
fn http_version_resolves_through_the_inheritance_chain() {
|
||||
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: "Test".to_string(),
|
||||
setting_http_version: HttpVersion::Http2,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let folder = db
|
||||
.upsert_folder(
|
||||
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert folder");
|
||||
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
folder_id: Some(folder.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
let (folder, request) = query_manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Test".to_string(),
|
||||
setting_http_version: HttpVersion::Http2,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
let folder = db.upsert_folder(
|
||||
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
folder_id: Some(folder.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, crate::error::Error>((folder, request))
|
||||
})
|
||||
.expect("Failed to seed");
|
||||
|
||||
// No overrides, so the workspace base value applies
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
let resolved = query_manager
|
||||
.connect()
|
||||
.resolve_settings_for_http_request(&request)
|
||||
.expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Http2);
|
||||
assert_eq!(resolved.http_version.source_model, "workspace");
|
||||
|
||||
// A folder override beats the workspace base
|
||||
db.upsert_folder(
|
||||
&Folder {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Http1,
|
||||
},
|
||||
..folder
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.expect("Failed to update folder");
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_folder(
|
||||
&Folder {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Http1,
|
||||
},
|
||||
..folder
|
||||
},
|
||||
source,
|
||||
)
|
||||
})
|
||||
.expect("Failed to update folder");
|
||||
let resolved = query_manager
|
||||
.connect()
|
||||
.resolve_settings_for_http_request(&request)
|
||||
.expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Http1);
|
||||
assert_eq!(resolved.http_version.source_model, "folder");
|
||||
|
||||
// A request override beats them both
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Auto,
|
||||
let request = query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
setting_http_version: InheritedHttpVersionSetting {
|
||||
enabled: true,
|
||||
value: HttpVersion::Auto,
|
||||
},
|
||||
..request
|
||||
},
|
||||
..request
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
source,
|
||||
)
|
||||
})
|
||||
.expect("Failed to update request");
|
||||
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||
let resolved = query_manager
|
||||
.connect()
|
||||
.resolve_settings_for_http_request(&request)
|
||||
.expect("Failed to resolve");
|
||||
assert_eq!(resolved.http_version.value, HttpVersion::Auto);
|
||||
assert_eq!(resolved.http_version.source_model, "http_request");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{HttpResponseEvent, HttpResponseEventIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -7,7 +7,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_http_response_events(&self, response_id: &str) -> Result<Vec<HttpResponseEvent>> {
|
||||
self.find_many(HttpResponseEventIden::ResponseId, response_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_http_response_event(
|
||||
&self,
|
||||
http_response_event: &HttpResponseEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
@@ -31,20 +31,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit)
|
||||
}
|
||||
|
||||
/// Returns the number of responses deleted.
|
||||
pub fn delete_all_http_responses_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<usize> {
|
||||
let responses = self.list_http_responses_for_request(request_id, None)?;
|
||||
let count = responses.len();
|
||||
for m in responses {
|
||||
self.delete(&m, source)?;
|
||||
}
|
||||
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
|
||||
@@ -55,19 +41,24 @@ impl<'a> ClientDb<'a> {
|
||||
///
|
||||
/// Returns the number of orphaned bodies deleted.
|
||||
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
let orphaned = blobs
|
||||
.connect()
|
||||
.list_body_ids()?
|
||||
.into_iter()
|
||||
.filter(|body_id| {
|
||||
let response_id = body_id.split('.').next().unwrap_or_default();
|
||||
self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_none()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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;
|
||||
blobs.with_tx(|b| {
|
||||
for body_id in &orphaned {
|
||||
b.delete_chunks(body_id)?;
|
||||
}
|
||||
blob_ctx.delete_chunks(&body_id)?;
|
||||
deleted += 1;
|
||||
}
|
||||
Ok::<_, crate::error::Error>(())
|
||||
})?;
|
||||
|
||||
Ok(deleted)
|
||||
Ok(orphaned.len())
|
||||
}
|
||||
|
||||
/// Delete response body data (blob chunks and body files) whose owning HTTP
|
||||
@@ -96,8 +87,7 @@ impl<'a> ClientDb<'a> {
|
||||
let Some(response_id) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some()
|
||||
{
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
}
|
||||
if fs::remove_file(&path).is_ok() {
|
||||
@@ -108,6 +98,22 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// Returns the number of responses deleted.
|
||||
pub fn delete_all_http_responses_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<usize> {
|
||||
let responses = self.list_http_responses_for_request(request_id, None)?;
|
||||
let count = responses.len();
|
||||
for m in responses {
|
||||
self.delete(&m, source)?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Returns the number of responses deleted.
|
||||
pub fn delete_all_http_responses_for_workspace(
|
||||
@@ -138,9 +144,8 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
// Delete request body blobs (pattern: {response_id}.request)
|
||||
let blob_ctx = blob_manager.connect();
|
||||
let body_id = format!("{}.request", http_response.id);
|
||||
if let Err(e) = blob_ctx.delete_chunks(&body_id) {
|
||||
if let Err(e) = blob_manager.with_tx(|b| b.delete_chunks(&body_id)) {
|
||||
error!("Failed to delete request body blobs: {}", e);
|
||||
}
|
||||
|
||||
@@ -187,36 +192,50 @@ impl<'a> ClientDb<'a> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::blob_manager::{BlobManager, BodyChunk};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Error;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
/// A workspace, a request, and one response that still exists.
|
||||
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
|
||||
fn seed_live_response(
|
||||
query_manager: &QueryManager,
|
||||
blob_manager: &BlobManager,
|
||||
) -> HttpResponse {
|
||||
let source = &UpdateSource::Background;
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "GC Test".to_string(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
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")
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace { name: "GC Test".to_string(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
blob_manager,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed response")
|
||||
}
|
||||
|
||||
fn insert_bodies(blob_manager: &BlobManager, body_ids: &[&str]) {
|
||||
blob_manager
|
||||
.with_tx(|b| {
|
||||
for id in body_ids {
|
||||
b.insert_chunk(&BodyChunk::new(*id, 0, b"data".to_vec()))?;
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.expect("Failed to insert chunks");
|
||||
}
|
||||
|
||||
/// What a browser host runs: no filesystem, so bodies exist only as blob
|
||||
@@ -224,23 +243,21 @@ mod tests {
|
||||
#[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 = seed_live_response(&query_manager, &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();
|
||||
}
|
||||
insert_bodies(
|
||||
&blob_manager,
|
||||
&[
|
||||
&live.id,
|
||||
&live_request_body_id,
|
||||
"rs_gone",
|
||||
"rs_gone.request",
|
||||
],
|
||||
);
|
||||
|
||||
let deleted = db
|
||||
let deleted = query_manager
|
||||
.connect()
|
||||
.delete_orphaned_response_body_blobs(&blob_manager)
|
||||
.expect("Failed to GC response body blobs");
|
||||
assert_eq!(deleted, 2);
|
||||
@@ -255,24 +272,18 @@ mod tests {
|
||||
#[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 = seed_live_response(&query_manager, &blob_manager);
|
||||
let live_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_body_id, 0, b"live".to_vec())).unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
insert_bodies(&blob_manager, &[&live_body_id, "rs_gone.request"]);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("yaak-blob-gc-test-{}", live.id));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(&live.id), b"live").unwrap();
|
||||
std::fs::write(dir.join("rs_gone"), b"dead").unwrap();
|
||||
|
||||
let deleted = db
|
||||
let deleted = query_manager
|
||||
.connect()
|
||||
.delete_orphaned_response_bodies(&blob_manager, &dir)
|
||||
.expect("Failed to GC response bodies");
|
||||
assert_eq!(deleted, 2);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{ImportSourceResource, ImportSourceResourceIden};
|
||||
use sea_query::ExprTrait;
|
||||
@@ -20,7 +20,9 @@ impl<'a> ClientDb<'a> {
|
||||
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
|
||||
Ok(items.filter_map(|v| v.ok()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_import_source_resource(
|
||||
&self,
|
||||
resource: &ImportSourceResource,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{ImportSource, ImportSourceIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -25,7 +25,9 @@ impl<'a> ClientDb<'a> {
|
||||
let sources = self.list_import_sources(workspace_id)?;
|
||||
Ok(sources.into_iter().find(|s| s.importer == importer && s.origin == origin))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_import_source(
|
||||
&self,
|
||||
import_source: &ImportSource,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -89,7 +89,9 @@ impl<'a> ClientDb<'a> {
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), KeyValue::from_row).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn set_key_value_dte(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
||||
@@ -24,8 +24,8 @@ mod websocket_events;
|
||||
mod websocket_requests;
|
||||
mod workspace_metas;
|
||||
pub mod workspaces;
|
||||
pub use model_changes::PersistedModelChange;
|
||||
pub(crate) use duplicate_name::conflict_free_name;
|
||||
pub use model_changes::PersistedModelChange;
|
||||
|
||||
const MAX_HISTORY_ITEMS: usize = 20;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::util::ModelPayload;
|
||||
use rusqlite::params;
|
||||
@@ -69,7 +69,9 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
Ok(items.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn prune_model_changes_older_than_days(&self, days: i64) -> Result<usize> {
|
||||
let offset = format!("-{days} days");
|
||||
Ok(self.conn().resolve().execute(
|
||||
@@ -101,23 +103,36 @@ mod tests {
|
||||
use crate::util::{ModelChangeEvent, UpdateSource};
|
||||
use serde_json::json;
|
||||
|
||||
/// Startup bootstraps rows of its own; these tests count only their own.
|
||||
fn clear_changes(query_manager: &crate::query_manager::QueryManager) {
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.conn().resolve().execute("DELETE FROM model_changes", [])?;
|
||||
Ok::<_, crate::error::Error>(())
|
||||
})
|
||||
.expect("Failed to clear model changes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_model_changes_for_upsert_and_delete() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
clear_changes(&query_manager);
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Changes Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
let workspace = query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Changes Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
let created_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(created_changes.len(), 1);
|
||||
assert_eq!(created_changes[0].payload.model.id(), workspace.id);
|
||||
@@ -128,9 +143,14 @@ mod tests {
|
||||
));
|
||||
assert!(matches!(created_changes[0].payload.update_source, UpdateSource::Sync));
|
||||
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
drop(db);
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
})
|
||||
.expect("Failed to delete workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
let all_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(all_changes.len(), 2);
|
||||
assert!(matches!(all_changes[1].payload.change, ModelChangeEvent::Delete));
|
||||
@@ -146,19 +166,23 @@ mod tests {
|
||||
#[test]
|
||||
fn prunes_old_model_changes() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(changes.len(), 1);
|
||||
|
||||
@@ -170,31 +194,41 @@ mod tests {
|
||||
)
|
||||
.expect("Failed to age model change row");
|
||||
|
||||
let pruned =
|
||||
db.prune_model_changes_older_than_days(30).expect("Failed to prune model changes");
|
||||
drop(db);
|
||||
let pruned = query_manager
|
||||
.with_tx(|db| db.prune_model_changes_older_than_days(30))
|
||||
.expect("Failed to prune model changes");
|
||||
assert_eq!(pruned, 1);
|
||||
assert!(db.list_model_changes_after(0, 10).expect("Failed to list changes").is_empty());
|
||||
assert!(
|
||||
query_manager
|
||||
.connect()
|
||||
.list_model_changes_after(0, 10)
|
||||
.expect("Failed to list changes")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_model_changes_since_uses_timestamp_with_id_tiebreaker() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Cursor Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)?;
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
})
|
||||
.expect("Failed to seed changes");
|
||||
|
||||
let db = query_manager.connect();
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Cursor Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
|
||||
.expect("Failed to delete workspace");
|
||||
|
||||
let all = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
@@ -213,19 +247,23 @@ mod tests {
|
||||
#[test]
|
||||
fn prunes_old_model_changes_by_hours() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Hour Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let db = query_manager.connect();
|
||||
|
||||
db.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Prune Hour Test".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
|
||||
let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
|
||||
assert_eq!(changes.len(), 1);
|
||||
|
||||
@@ -237,14 +275,17 @@ mod tests {
|
||||
)
|
||||
.expect("Failed to age model change row");
|
||||
|
||||
let pruned =
|
||||
db.prune_model_changes_older_than_hours(1).expect("Failed to prune model changes");
|
||||
drop(db);
|
||||
let pruned = query_manager
|
||||
.with_tx(|db| db.prune_model_changes_older_than_hours(1))
|
||||
.expect("Failed to prune model changes");
|
||||
assert_eq!(pruned, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_model_changes_deserializes_http_response_event_payload() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
clear_changes(&query_manager);
|
||||
let db = query_manager.connect();
|
||||
|
||||
let payload = json!({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{PluginKeyValue, PluginKeyValueIden};
|
||||
use sea_query::ExprTrait;
|
||||
@@ -22,7 +22,9 @@ impl<'a> ClientDb<'a> {
|
||||
.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn set_plugin_key_value(
|
||||
&self,
|
||||
plugin_name: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{Plugin, PluginIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -15,7 +15,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_plugins(&self) -> Result<Vec<Plugin>> {
|
||||
self.find_all()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
|
||||
self.delete(plugin, source)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,61 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{EditorKeymap, Settings, SettingsIden};
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
/// The settings row, or the defaults if it has not been written yet.
|
||||
/// [`WriteDb::ensure_settings`] persists it at startup.
|
||||
pub fn get_settings(&self) -> Settings {
|
||||
let id = "default".to_string();
|
||||
self.find_optional::<Settings>(SettingsIden::Id, "default").unwrap_or_else(default_settings)
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = self.find_optional::<Settings>(SettingsIden::Id, &id) {
|
||||
return s;
|
||||
};
|
||||
|
||||
let settings = Settings {
|
||||
model: "settings".to_string(),
|
||||
id,
|
||||
created_at: Default::default(),
|
||||
updated_at: Default::default(),
|
||||
|
||||
appearance: "system".to_string(),
|
||||
client_certificates: Vec::new(),
|
||||
editor_font_size: 12,
|
||||
editor_font: None,
|
||||
editor_keymap: EditorKeymap::Default,
|
||||
editor_soft_wrap: true,
|
||||
interface_font_size: 14,
|
||||
interface_scale: 1.0,
|
||||
interface_font: None,
|
||||
hide_window_controls: false,
|
||||
use_native_titlebar: false,
|
||||
open_workspace_new_window: None,
|
||||
proxy: None,
|
||||
theme_dark: "yaak-dark".to_string(),
|
||||
theme_light: "yaak-light".to_string(),
|
||||
update_channel: "stable".to_string(),
|
||||
autoupdate: true,
|
||||
colored_methods: false,
|
||||
hide_license_badge: false,
|
||||
prompt_feedback: true,
|
||||
auto_download_updates: true,
|
||||
check_notifications: true,
|
||||
hotkeys: HashMap::new(),
|
||||
};
|
||||
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// Create the settings row if it does not exist.
|
||||
pub fn ensure_settings(&self) -> Result<Settings> {
|
||||
if let Some(s) = self.find_optional::<Settings>(SettingsIden::Id, "default") {
|
||||
return Ok(s);
|
||||
}
|
||||
self.upsert(&default_settings(), &UpdateSource::Background)
|
||||
}
|
||||
|
||||
pub fn upsert_settings(&self, settings: &Settings, source: &UpdateSource) -> Result<Settings> {
|
||||
self.upsert(settings, source)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_settings() -> Settings {
|
||||
Settings {
|
||||
model: "settings".to_string(),
|
||||
id: "default".to_string(),
|
||||
created_at: Default::default(),
|
||||
updated_at: Default::default(),
|
||||
|
||||
appearance: "system".to_string(),
|
||||
client_certificates: Vec::new(),
|
||||
editor_font_size: 12,
|
||||
editor_font: None,
|
||||
editor_keymap: EditorKeymap::Default,
|
||||
editor_soft_wrap: true,
|
||||
interface_font_size: 14,
|
||||
interface_scale: 1.0,
|
||||
interface_font: None,
|
||||
hide_window_controls: false,
|
||||
use_native_titlebar: false,
|
||||
open_workspace_new_window: None,
|
||||
proxy: None,
|
||||
theme_dark: "yaak-dark".to_string(),
|
||||
theme_light: "yaak-light".to_string(),
|
||||
update_channel: "stable".to_string(),
|
||||
autoupdate: true,
|
||||
colored_methods: false,
|
||||
hide_license_badge: false,
|
||||
prompt_feedback: true,
|
||||
auto_download_updates: true,
|
||||
check_notifications: true,
|
||||
hotkeys: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -12,10 +12,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_one(SyncStateIden::Id, id)
|
||||
}
|
||||
|
||||
pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
|
||||
self.upsert(sync_state, &UpdateSource::Sync)
|
||||
}
|
||||
|
||||
pub fn list_sync_states_for_workspace(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -34,6 +30,12 @@ impl<'a> ClientDb<'a> {
|
||||
let items = stmt.query_map(&*params.as_params(), SyncState::from_row)?;
|
||||
Ok(items.map(|v| v.unwrap()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
|
||||
self.upsert(sync_state, &UpdateSource::Sync)
|
||||
}
|
||||
|
||||
pub fn delete_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {
|
||||
self.delete(sync_state, &UpdateSource::Sync)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConnectionState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
@@ -13,6 +13,22 @@ impl<'a> ClientDb<'a> {
|
||||
self.find_one(WebsocketConnectionIden::Id, id)
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::RequestId, request_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_all_websocket_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
@@ -37,20 +53,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
|
||||
pub fn list_websocket_connections_for_request(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
self.find_many(WebsocketConnectionIden::RequestId, request_id, None)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_connection(
|
||||
&self,
|
||||
websocket_connection: &WebsocketConnection,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{WebsocketEvent, WebsocketEventIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn list_websocket_events(&self, connection_id: &str) -> Result<Vec<WebsocketEvent>> {
|
||||
self.find_many(WebsocketEventIden::ConnectionId, connection_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn upsert_websocket_event(
|
||||
&self,
|
||||
websocket_event: &WebsocketEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{conflict_free_name, merge_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, Folder, FolderIden, HttpRequestHeader, ResolvedHttpRequestSettings, ResolvedSetting,
|
||||
@@ -34,50 +34,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
|
||||
self.delete(websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let request = self.get_websocket_request(id)?;
|
||||
self.delete_websocket_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let mut websocket_request = websocket_request.clone();
|
||||
websocket_request.id = "".to_string();
|
||||
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_websocket_requests(&websocket_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == websocket_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
|
||||
self.upsert(&websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.upsert(websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
@@ -168,3 +124,49 @@ impl<'a> ClientDb<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn delete_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
|
||||
self.delete(websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn delete_websocket_request_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let request = self.get_websocket_request(id)?;
|
||||
self.delete_websocket_request(&request, source)
|
||||
}
|
||||
|
||||
pub fn duplicate_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let mut websocket_request = websocket_request.clone();
|
||||
websocket_request.id = "".to_string();
|
||||
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_websocket_requests(&websocket_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == websocket_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
|
||||
self.upsert(&websocket_request, source)
|
||||
}
|
||||
|
||||
pub fn upsert_websocket_request(
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.upsert(websocket_request, source)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{WorkspaceMeta, WorkspaceMetaIden};
|
||||
use crate::util::UpdateSource;
|
||||
@@ -10,18 +10,12 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn list_workspace_metas(&self, workspace_id: &str) -> Result<Vec<WorkspaceMeta>> {
|
||||
let mut workspace_metas =
|
||||
self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)?;
|
||||
|
||||
if workspace_metas.is_empty() {
|
||||
let wm = WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() };
|
||||
workspace_metas.push(self.upsert_workspace_meta(&wm, &UpdateSource::Background)?)
|
||||
}
|
||||
|
||||
Ok(workspace_metas)
|
||||
self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn ensure_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
|
||||
let workspace_meta = self.get_workspace_meta(workspace_id);
|
||||
if let Some(workspace_meta) = workspace_meta {
|
||||
return Ok(workspace_meta);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::merge_headers;
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
|
||||
@@ -8,9 +8,9 @@ use crate::models::{
|
||||
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
|
||||
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
|
||||
WebsocketConnection,
|
||||
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
|
||||
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
|
||||
WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden,
|
||||
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta,
|
||||
WorkspaceMetaIden,
|
||||
};
|
||||
use crate::util::UpdateSource;
|
||||
use log::warn;
|
||||
@@ -23,119 +23,7 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn list_workspaces(&self) -> Result<Vec<Workspace>> {
|
||||
let mut workspaces = self.find_all()?;
|
||||
|
||||
if workspaces.is_empty() {
|
||||
workspaces.push(self.upsert_workspace(
|
||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)?)
|
||||
}
|
||||
|
||||
Ok(workspaces)
|
||||
}
|
||||
|
||||
/// Delete a workspace and everything in it.
|
||||
///
|
||||
/// Children are bulk-deleted with one statement per table and are NOT
|
||||
/// individually recorded in model_changes or emitted as events — the single
|
||||
/// workspace delete event implies the subtree (see [`ModelChangeEvent::Delete`]).
|
||||
/// This keeps huge workspaces (thousands of requests) fast and avoids
|
||||
/// flooding event consumers.
|
||||
pub fn delete_workspace(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let wid = workspace.id.as_str();
|
||||
|
||||
// Collect response cleanup targets before their rows disappear. The actual
|
||||
// cleanup runs at the end: response bodies live on disk and in the blob DB,
|
||||
// which don't participate in this transaction, so removing them must wait
|
||||
// until every statement that could fail (and roll back the rows) is done.
|
||||
let responses = self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, wid, None)?;
|
||||
|
||||
// Sync and the CLI call this on a plain connection where each statement
|
||||
// would otherwise commit on its own, leaving a partially-deleted workspace
|
||||
// if one fails. A savepoint makes the cascade atomic there, and nests
|
||||
// harmlessly inside the interactive path's transaction.
|
||||
let conn = self.conn().resolve();
|
||||
conn.execute_batch("SAVEPOINT delete_workspace")?;
|
||||
|
||||
let result: Result<Workspace> = (|| {
|
||||
self.delete_many_untracked::<HttpResponseEvent>(
|
||||
HttpResponseEventIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<HttpResponse>(HttpResponseIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<HttpRequest>(HttpRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcEvent>(GrpcEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcConnection>(GrpcConnectionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcRequest>(GrpcRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketEvent>(WebsocketEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketConnection>(
|
||||
WebsocketConnectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<WebsocketRequest>(WebsocketRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GraphQlIntrospection>(
|
||||
GraphQlIntrospectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
|
||||
for import_source in self.list_import_sources(wid)? {
|
||||
self.delete_import_source_resources(&import_source.id)?;
|
||||
}
|
||||
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
self.delete(workspace, source)
|
||||
})();
|
||||
|
||||
let deleted = match result {
|
||||
Ok(deleted) => {
|
||||
conn.execute_batch("RELEASE delete_workspace")?;
|
||||
deleted
|
||||
}
|
||||
Err(e) => {
|
||||
let _ =
|
||||
conn.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Best-effort cleanup of response bodies (disk files and blob chunks).
|
||||
// Failures only orphan unreferenced data, and are logged.
|
||||
let blob_ctx = blobs.connect();
|
||||
for m in responses {
|
||||
if let Some(p) = m.body_path {
|
||||
if let Err(e) = std::fs::remove_file(&p) {
|
||||
warn!("Failed to delete response body file {p:?}: {e}");
|
||||
}
|
||||
}
|
||||
if let Err(e) = blob_ctx.delete_chunks_like(&format!("{}.%", m.id)) {
|
||||
warn!("Failed to delete blobs for response {}: {e}", m.id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub fn delete_workspace_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let workspace = self.get_workspace(id)?;
|
||||
self.delete_workspace(&workspace, source, blobs)
|
||||
}
|
||||
|
||||
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
|
||||
self.upsert(w, source)
|
||||
self.find_all()
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_workspace(
|
||||
@@ -190,6 +78,100 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
/// There is always at least one workspace. Called at startup and after a
|
||||
/// workspace is deleted.
|
||||
pub fn ensure_default_workspace(&self) -> Result<()> {
|
||||
if self.find_all::<Workspace>()?.is_empty() {
|
||||
self.upsert_workspace(
|
||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a workspace and everything in it.
|
||||
///
|
||||
/// Children are bulk-deleted with one statement per table and are NOT
|
||||
/// individually recorded in model_changes or emitted as events — the single
|
||||
/// workspace delete event implies the subtree (see [`ModelChangeEvent::Delete`]).
|
||||
/// This keeps huge workspaces (thousands of requests) fast and avoids
|
||||
/// flooding event consumers.
|
||||
pub fn delete_workspace(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let wid = workspace.id.as_str();
|
||||
|
||||
// Collect response cleanup targets before their rows disappear. The actual
|
||||
// cleanup runs at the end: response bodies live on disk and in the blob DB,
|
||||
// which don't participate in this transaction, so removing them must wait
|
||||
// until every statement that could fail (and roll back the rows) is done.
|
||||
let responses = self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, wid, None)?;
|
||||
|
||||
self.delete_many_untracked::<HttpResponseEvent>(HttpResponseEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<HttpResponse>(HttpResponseIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<HttpRequest>(HttpRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcEvent>(GrpcEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcConnection>(GrpcConnectionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GrpcRequest>(GrpcRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketEvent>(WebsocketEventIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WebsocketConnection>(
|
||||
WebsocketConnectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<WebsocketRequest>(WebsocketRequestIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<GraphQlIntrospection>(
|
||||
GraphQlIntrospectionIden::WorkspaceId,
|
||||
wid,
|
||||
)?;
|
||||
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
|
||||
for import_source in self.list_import_sources(wid)? {
|
||||
self.delete_import_source_resources(&import_source.id)?;
|
||||
}
|
||||
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
let deleted = self.delete(workspace, source)?;
|
||||
self.ensure_default_workspace()?;
|
||||
|
||||
// Best-effort cleanup of response bodies (disk files and blob chunks).
|
||||
// Failures only orphan unreferenced data, and are logged.
|
||||
for m in responses {
|
||||
if let Some(p) = m.body_path {
|
||||
if let Err(e) = std::fs::remove_file(&p) {
|
||||
warn!("Failed to delete response body file {p:?}: {e}");
|
||||
}
|
||||
}
|
||||
let pattern = format!("{}.%", m.id);
|
||||
if let Err(e) = blobs.with_tx(|b| b.delete_chunks_like(&pattern)) {
|
||||
warn!("Failed to delete blobs for response {}: {e}", m.id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub fn delete_workspace_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &UpdateSource,
|
||||
blobs: &BlobManager,
|
||||
) -> Result<Workspace> {
|
||||
let workspace = self.get_workspace(id)?;
|
||||
self.delete_workspace(&workspace, source, blobs)
|
||||
}
|
||||
|
||||
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
|
||||
self.upsert(w, source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global default headers that are always sent with requests unless overridden.
|
||||
/// These are prepended to the inheritance chain so workspace/folder/request headers
|
||||
/// can override or disable them.
|
||||
|
||||
@@ -1,65 +1,82 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::client_db::{ClientDb, WriteDb};
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::util::ModelPayload;
|
||||
use rusqlite::{Transaction, TransactionBehavior};
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
|
||||
|
||||
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
|
||||
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
|
||||
// whole app whenever the pool is exhausted.
|
||||
/// Reads come from a pool; writes go through one connection.
|
||||
///
|
||||
/// SQLite in WAL mode lets many readers run alongside a single writer, and
|
||||
/// never more than one writer. A second in-process writer can only wait, and
|
||||
/// while it waits in the busy handler it sleeps, retries, and keeps its pool
|
||||
/// slot. Enough of those and the pool is full of writers that are all asleep,
|
||||
/// and every read in the app queues behind them. Giving writes exactly one
|
||||
/// connection turns that into a plain queue: the next write starts the moment
|
||||
/// the previous one commits, and it never takes a slot a read could use.
|
||||
///
|
||||
/// The pools are internally synchronized — don't wrap them in a Mutex. A Mutex
|
||||
/// held across the blocking `get()` serializes every DB access behind the
|
||||
/// slowest waiter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueryManager {
|
||||
pool: SqlitePool,
|
||||
readers: SqlitePool,
|
||||
writer: SqlitePool,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
}
|
||||
|
||||
impl QueryManager {
|
||||
pub fn new(pool: SqlitePool, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
QueryManager { pool, events_tx }
|
||||
/// `writer` must be a pool with a single connection; see [`crate::init_standalone`].
|
||||
pub fn new(
|
||||
readers: SqlitePool,
|
||||
writer: SqlitePool,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
) -> Self {
|
||||
QueryManager { readers, writer, events_tx }
|
||||
}
|
||||
|
||||
/// A read handle from the reader pool.
|
||||
pub fn connect(&self) -> ClientDb<'_> {
|
||||
let conn = self.pool.get().expect("Failed to get a new DB connection from the pool");
|
||||
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
|
||||
ClientDb::new(ctx, self.events_tx.clone())
|
||||
}
|
||||
|
||||
pub fn with_conn<F, T>(&self, func: F) -> T
|
||||
where
|
||||
F: FnOnce(&ClientDb) -> T,
|
||||
{
|
||||
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
|
||||
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
|
||||
let db = ClientDb::new(ctx, self.events_tx.clone());
|
||||
|
||||
func(&db)
|
||||
let conn = self.readers.get().expect("Failed to get a new DB connection from the pool");
|
||||
ClientDb::new(DbContext::new(ConnectionOrTx::Connection(conn)))
|
||||
}
|
||||
|
||||
/// Run `func` in a transaction on the writer connection.
|
||||
///
|
||||
/// Waits for any write in progress to commit first, and fails with a pool
|
||||
/// error if that takes longer than the pool's timeout. Do not call this
|
||||
/// from inside another `with_tx` closure: the inner call would wait for
|
||||
/// the outer transaction, which is waiting on it.
|
||||
///
|
||||
/// Model events for the writes are sent once the transaction commits.
|
||||
pub fn with_tx<T, E>(
|
||||
&self,
|
||||
func: impl FnOnce(&ClientDb) -> std::result::Result<T, E>,
|
||||
func: impl FnOnce(&WriteDb) -> std::result::Result<T, E>,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
E: From<crate::error::Error>,
|
||||
{
|
||||
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
|
||||
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
|
||||
// the pool never hands out `&mut`.
|
||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||
.expect("Failed to start DB transaction");
|
||||
.map_err(crate::error::Error::SqlError)?;
|
||||
|
||||
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
|
||||
let db = ClientDb::new(ctx, self.events_tx.clone());
|
||||
let db =
|
||||
WriteDb::new(DbContext::new(ConnectionOrTx::Transaction(&tx)), self.events_tx.clone());
|
||||
|
||||
match func(&db) {
|
||||
Ok(val) => {
|
||||
let events = db.into_events();
|
||||
tx.commit()
|
||||
.map_err(|e| GenericError(format!("Failed to commit transaction {e:?}")))?;
|
||||
for payload in events {
|
||||
let _ = self.events_tx.send(payload);
|
||||
}
|
||||
Ok(val)
|
||||
}
|
||||
Err(e) => {
|
||||
drop(db);
|
||||
tx.rollback()
|
||||
.map_err(|e| GenericError(format!("Failed to rollback transaction {e:?}")))?;
|
||||
Err(e)
|
||||
|
||||
@@ -125,19 +125,11 @@ pub enum ImportPlanWarningLevel {
|
||||
|
||||
impl ImportPlanWarning {
|
||||
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
detail: detail.into(),
|
||||
level: ImportPlanWarningLevel::Info,
|
||||
}
|
||||
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Info }
|
||||
}
|
||||
|
||||
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
detail: detail.into(),
|
||||
level: ImportPlanWarningLevel::Warning,
|
||||
}
|
||||
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Warning }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +277,7 @@ pub fn get_workspace_export_resources(
|
||||
data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);
|
||||
data.resources.environments.append(
|
||||
&mut db
|
||||
.list_environments_ensure_base(workspace_id)?
|
||||
.list_environments(workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|e| include_private_environments || e.public)
|
||||
.collect(),
|
||||
|
||||
@@ -24,11 +24,7 @@ pub async fn delete_and_uninstall(
|
||||
Some(label) => UpdateSource::from_window_label(label),
|
||||
None => UpdateSource::Background,
|
||||
};
|
||||
// Scope the db connection so it doesn't live across await
|
||||
let plugin = {
|
||||
let db = query_manager.connect();
|
||||
db.delete_plugin_by_id(plugin_id, &update_source)?
|
||||
};
|
||||
let plugin = query_manager.with_tx(|db| db.delete_plugin_by_id(plugin_id, &update_source))?;
|
||||
if let Err(err) = plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await {
|
||||
if !matches!(err, PluginNotFoundErr(_)) {
|
||||
return Err(err);
|
||||
@@ -72,9 +68,7 @@ pub async fn download_and_install(
|
||||
zip_extract::extract(Cursor::new(&bytes), &plugin_dir, true)?;
|
||||
info!("Extracted plugin {} to {}", plugin_version.id, plugin_dir_str);
|
||||
|
||||
// Scope the db connection so it doesn't live across await
|
||||
let plugin = {
|
||||
let db = query_manager.connect();
|
||||
let plugin = query_manager.with_tx(|db| {
|
||||
db.upsert_plugin(
|
||||
&Plugin {
|
||||
id: plugin_version.id.clone(),
|
||||
@@ -86,8 +80,8 @@ pub async fn download_and_install(
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)?
|
||||
};
|
||||
)
|
||||
})?;
|
||||
|
||||
plugin_manager.add_plugin(plugin_context, &plugin).await?;
|
||||
|
||||
|
||||
@@ -187,9 +187,7 @@ impl PluginManager {
|
||||
}
|
||||
|
||||
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
|
||||
// Scope the db connection so the future stays Send across the await below
|
||||
let plugins = {
|
||||
let db = query_manager.connect();
|
||||
let plugins = query_manager.with_tx(|db| {
|
||||
for dir in &bundled_dirs {
|
||||
if db.get_plugin_by_directory(dir).is_none() {
|
||||
db.upsert_plugin(
|
||||
@@ -204,8 +202,8 @@ impl PluginManager {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
db.list_plugins()?
|
||||
};
|
||||
db.list_plugins()
|
||||
})?;
|
||||
|
||||
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
|
||||
if !init_errors.is_empty() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::error::Result;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use crate::models::SyncModel;
|
||||
use chrono::Utc;
|
||||
use log::{info, warn};
|
||||
@@ -11,8 +10,12 @@ use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{SyncState, WorkspaceMeta};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::{ClientDb, WriteDb};
|
||||
use yaak_models::models::{
|
||||
Environment, Folder, GrpcRequest, HttpRequest, SyncState, WebsocketRequest, Workspace,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::{UpdateSource, get_workspace_export_resources};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
@@ -336,17 +339,40 @@ fn workspace_models(db: &ClientDb, version: &str, workspace_id: &str) -> Result<
|
||||
Ok(sync_models)
|
||||
}
|
||||
|
||||
/// Apply sync operations to the filesystem and database.
|
||||
/// Returns a list of SyncStateOps that should be applied afterward.
|
||||
pub fn apply_sync_ops(
|
||||
db: &ClientDb,
|
||||
blobs: &BlobManager,
|
||||
/// The database half of a sync apply, ready to run once the files are on disk.
|
||||
pub struct PendingDbSyncOps {
|
||||
sync_state_ops: Vec<SyncStateOp>,
|
||||
deletes: Vec<SyncModel>,
|
||||
workspaces: Vec<Workspace>,
|
||||
environments: Vec<Environment>,
|
||||
folders: Vec<Folder>,
|
||||
http_requests: Vec<HttpRequest>,
|
||||
grpc_requests: Vec<GrpcRequest>,
|
||||
websocket_requests: Vec<WebsocketRequest>,
|
||||
}
|
||||
|
||||
/// Apply the filesystem half of the sync operations: create, rewrite and
|
||||
/// delete files. Returns the database half, for [`apply_db_sync_ops`].
|
||||
///
|
||||
/// Split this way so the file work, which can be slow, happens before the
|
||||
/// write transaction is opened rather than inside it.
|
||||
pub fn apply_fs_sync_ops(
|
||||
workspace_id: &str,
|
||||
sync_dir: &Path,
|
||||
sync_ops: Vec<SyncOp>,
|
||||
) -> Result<Vec<SyncStateOp>> {
|
||||
) -> Result<PendingDbSyncOps> {
|
||||
let mut pending = PendingDbSyncOps {
|
||||
sync_state_ops: Vec::new(),
|
||||
deletes: Vec::new(),
|
||||
workspaces: Vec::new(),
|
||||
environments: Vec::new(),
|
||||
folders: Vec::new(),
|
||||
http_requests: Vec::new(),
|
||||
grpc_requests: Vec::new(),
|
||||
websocket_requests: Vec::new(),
|
||||
};
|
||||
if sync_ops.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
return Ok(pending);
|
||||
}
|
||||
|
||||
info!(
|
||||
@@ -354,21 +380,13 @@ pub fn apply_sync_ops(
|
||||
sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ")
|
||||
);
|
||||
|
||||
let mut sync_state_ops = Vec::new();
|
||||
let mut workspaces_to_upsert = Vec::new();
|
||||
let mut environments_to_upsert = Vec::new();
|
||||
let mut folders_to_upsert = Vec::new();
|
||||
let mut http_requests_to_upsert = Vec::new();
|
||||
let mut grpc_requests_to_upsert = Vec::new();
|
||||
let mut websocket_requests_to_upsert = Vec::new();
|
||||
|
||||
for op in sync_ops {
|
||||
// Only apply things if workspace ID matches
|
||||
if op.workspace_id() != workspace_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
sync_state_ops.push(match op {
|
||||
let state_op = match op {
|
||||
SyncOp::FsCreate { model } => {
|
||||
let rel_path = derive_model_filename(&model);
|
||||
let abs_path = sync_dir.join(rel_path.clone());
|
||||
@@ -402,17 +420,7 @@ pub fn apply_sync_ops(
|
||||
},
|
||||
SyncOp::DbCreate { fs } => {
|
||||
let model_id = fs.model.id();
|
||||
|
||||
// Push updates to arrays so we can do them all in a single
|
||||
// batch upsert to make foreign keys happy
|
||||
match fs.model {
|
||||
SyncModel::Environment(m) => environments_to_upsert.push(m),
|
||||
SyncModel::Folder(m) => folders_to_upsert.push(m),
|
||||
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
|
||||
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
|
||||
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
|
||||
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
|
||||
};
|
||||
pending.push_upsert(fs.model);
|
||||
SyncStateOp::Create {
|
||||
model_id,
|
||||
checksum: fs.checksum.to_owned(),
|
||||
@@ -420,16 +428,7 @@ pub fn apply_sync_ops(
|
||||
}
|
||||
}
|
||||
SyncOp::DbUpdate { state, fs } => {
|
||||
// Push updates to arrays so we can do them all in a single
|
||||
// batch upsert to make foreign keys happy
|
||||
match fs.model {
|
||||
SyncModel::Environment(m) => environments_to_upsert.push(m),
|
||||
SyncModel::Folder(m) => folders_to_upsert.push(m),
|
||||
SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),
|
||||
SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),
|
||||
SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),
|
||||
SyncModel::Workspace(m) => workspaces_to_upsert.push(m),
|
||||
}
|
||||
pending.push_upsert(fs.model);
|
||||
SyncStateOp::Update {
|
||||
state: state.to_owned(),
|
||||
checksum: fs.checksum.to_owned(),
|
||||
@@ -437,20 +436,52 @@ pub fn apply_sync_ops(
|
||||
}
|
||||
}
|
||||
SyncOp::DbDelete { model, state } => {
|
||||
delete_model(db, blobs, &model)?;
|
||||
pending.deletes.push(model);
|
||||
SyncStateOp::Delete { state: state.to_owned() }
|
||||
}
|
||||
SyncOp::IgnorePrivate { .. } => SyncStateOp::NoOp,
|
||||
});
|
||||
};
|
||||
pending.sync_state_ops.push(state_op);
|
||||
}
|
||||
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
impl PendingDbSyncOps {
|
||||
/// Upserts are collected per model type and written in one batch so
|
||||
/// foreign keys are satisfied.
|
||||
fn push_upsert(&mut self, model: SyncModel) {
|
||||
match model {
|
||||
SyncModel::Environment(m) => self.environments.push(m),
|
||||
SyncModel::Folder(m) => self.folders.push(m),
|
||||
SyncModel::GrpcRequest(m) => self.grpc_requests.push(m),
|
||||
SyncModel::HttpRequest(m) => self.http_requests.push(m),
|
||||
SyncModel::WebsocketRequest(m) => self.websocket_requests.push(m),
|
||||
SyncModel::Workspace(m) => self.workspaces.push(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the database half of the sync operations.
|
||||
/// Returns a list of SyncStateOps that should be applied afterward.
|
||||
pub fn apply_db_sync_ops(
|
||||
db: &WriteDb,
|
||||
blobs: &BlobManager,
|
||||
workspace_id: &str,
|
||||
sync_dir: &Path,
|
||||
pending: PendingDbSyncOps,
|
||||
) -> Result<Vec<SyncStateOp>> {
|
||||
for model in &pending.deletes {
|
||||
delete_model(db, blobs, model)?;
|
||||
}
|
||||
|
||||
let upserted_models = db.batch_upsert(
|
||||
workspaces_to_upsert,
|
||||
environments_to_upsert,
|
||||
folders_to_upsert,
|
||||
http_requests_to_upsert,
|
||||
grpc_requests_to_upsert,
|
||||
websocket_requests_to_upsert,
|
||||
pending.workspaces,
|
||||
pending.environments,
|
||||
pending.folders,
|
||||
pending.http_requests,
|
||||
pending.grpc_requests,
|
||||
pending.websocket_requests,
|
||||
&UpdateSource::Sync,
|
||||
)?;
|
||||
|
||||
@@ -482,7 +513,7 @@ pub fn apply_sync_ops(
|
||||
}?;
|
||||
}
|
||||
|
||||
Ok(sync_state_ops)
|
||||
Ok(pending.sync_state_ops)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -504,7 +535,7 @@ pub enum SyncStateOp {
|
||||
}
|
||||
|
||||
pub fn apply_sync_state_ops(
|
||||
db: &ClientDb,
|
||||
db: &WriteDb,
|
||||
workspace_id: &str,
|
||||
sync_dir: &Path,
|
||||
ops: Vec<SyncStateOp>,
|
||||
@@ -549,7 +580,7 @@ fn derive_model_filename(m: &SyncModel) -> PathBuf {
|
||||
Path::new(&rel).to_path_buf()
|
||||
}
|
||||
|
||||
fn delete_model(db: &ClientDb, blobs: &BlobManager, model: &SyncModel) -> Result<()> {
|
||||
fn delete_model(db: &WriteDb, blobs: &BlobManager, model: &SyncModel) -> Result<()> {
|
||||
match model {
|
||||
SyncModel::Workspace(m) => {
|
||||
db.delete_workspace(&m, &UpdateSource::Sync, blobs)?;
|
||||
|
||||
BIN
Binary file not shown.
@@ -697,22 +697,22 @@ 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: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1123, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 214, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 164, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
|
||||
return ret;
|
||||
}
|
||||
|
||||
Binary file not shown.
+59
-35
@@ -104,7 +104,7 @@ 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) {
|
||||
if let Err(e) = queries.with_tx(|tx| yaak_lifecycle::on_launch(&lifecycle_host(), tx, &blobs)) {
|
||||
web_sys::console::warn_2(&"on_launch hook failed".into(), &js_error(e));
|
||||
}
|
||||
|
||||
@@ -266,10 +266,17 @@ fn dispatch(
|
||||
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
let e = js_error;
|
||||
// Opening a workspace is where the rows it is assumed to have get created
|
||||
host.queries
|
||||
.with_tx(|tx| {
|
||||
tx.ensure_base_environment(wid)?;
|
||||
tx.ensure_default_cookie_jar(wid)?;
|
||||
tx.ensure_workspace_meta(wid)?;
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})
|
||||
.map_err(e)?;
|
||||
list.extend(db.list_cookie_jars(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(
|
||||
db.list_environments_ensure_base(wid).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(db.list_environments(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_folders(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_grpc_connections(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_grpc_requests(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
@@ -291,9 +298,10 @@ fn dispatch(
|
||||
|
||||
"models_upsert" => {
|
||||
let req: ModelReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let id =
|
||||
models_ops::upsert_model(&db, &host.blobs, req.model, source).map_err(js_error)?;
|
||||
let id = host
|
||||
.queries
|
||||
.with_tx(|tx| models_ops::upsert_model(tx, &host.blobs, req.model, source))
|
||||
.map_err(js_error)?;
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
@@ -330,13 +338,14 @@ fn dispatch(
|
||||
let req: UpsertIntrospectionReq = from_js(payload)?;
|
||||
let saved = host
|
||||
.queries
|
||||
.connect()
|
||||
.upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
source,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
source,
|
||||
)
|
||||
})
|
||||
.map_err(js_error)?;
|
||||
to_json(saved)
|
||||
}
|
||||
@@ -366,10 +375,14 @@ fn dispatch(
|
||||
if req.before == req.after {
|
||||
return to_json(());
|
||||
}
|
||||
let db = host.queries.connect();
|
||||
let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?;
|
||||
let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after);
|
||||
db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?;
|
||||
host.queries
|
||||
.with_tx(|tx| {
|
||||
let jar = tx.get_cookie_jar(&req.cookie_jar_id)?;
|
||||
let cookies =
|
||||
apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after);
|
||||
tx.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source)
|
||||
})
|
||||
.map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
|
||||
@@ -378,26 +391,34 @@ fn dispatch(
|
||||
// writes fan out to every tab as `model_writes` like any other.
|
||||
"web_insert_http_response_events" => {
|
||||
let req: InsertResponseEventsReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
for event in req.events {
|
||||
let model = HttpResponseEvent::new(&req.response_id, &req.workspace_id, event);
|
||||
db.upsert_http_response_event(&model, source).map_err(js_error)?;
|
||||
}
|
||||
host.queries
|
||||
.with_tx(|tx| {
|
||||
for event in req.events {
|
||||
let model =
|
||||
HttpResponseEvent::new(&req.response_id, &req.workspace_id, event);
|
||||
tx.upsert_http_response_event(&model, source)?;
|
||||
}
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})
|
||||
.map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
|
||||
"cmd_get_workspace_meta" => {
|
||||
let req: WorkspaceIdReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let workspace = db.get_workspace(&req.workspace_id).map_err(js_error)?;
|
||||
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
|
||||
let workspace =
|
||||
host.queries.connect().get_workspace(&req.workspace_id).map_err(js_error)?;
|
||||
to_json(
|
||||
host.queries
|
||||
.with_tx(|tx| tx.ensure_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)
|
||||
.with_tx(|tx| tx.delete_all_http_responses_for_request(&req.request_id, source))
|
||||
.map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
@@ -569,16 +590,19 @@ pub fn blob_get(id: &str) -> Result<Option<Vec<u8>>> {
|
||||
pub fn blob_put(id: &str, bytes: &[u8]) -> Result<()> {
|
||||
const CHUNK: usize = 512 * 1024;
|
||||
with_host(|host| {
|
||||
let ctx = host.blobs.connect();
|
||||
ctx.delete_chunks(id).map_err(js_error)?;
|
||||
for (i, part) in bytes.chunks(CHUNK).enumerate() {
|
||||
ctx.insert_chunk(&BodyChunk::new(id, i as i32, part.to_vec())).map_err(js_error)?;
|
||||
}
|
||||
Ok(())
|
||||
host.blobs
|
||||
.with_tx(|b| {
|
||||
b.delete_chunks(id)?;
|
||||
for (i, part) in bytes.chunks(CHUNK).enumerate() {
|
||||
b.insert_chunk(&BodyChunk::new(id, i as i32, part.to_vec()))?;
|
||||
}
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})
|
||||
.map_err(js_error)
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn blob_delete(id: &str) -> Result<()> {
|
||||
with_host(|host| host.blobs.connect().delete_chunks(id).map_err(js_error))
|
||||
with_host(|host| host.blobs.with_tx(|b| b.delete_chunks(id)).map_err(js_error))
|
||||
}
|
||||
+238
-221
@@ -4,7 +4,7 @@ use log::info;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::client_db::{ClientDb, WriteDb};
|
||||
use yaak_models::models::{
|
||||
AnyModel, DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest,
|
||||
ImportSource, ImportSourceResource, UpsertModelInfo, WebsocketRequest, Workspace,
|
||||
@@ -351,7 +351,7 @@ pub fn commit_import_plan(
|
||||
})
|
||||
}
|
||||
|
||||
fn commit_plan_in_tx(db: &ClientDb, plan: ImportPlan) -> Result<BatchUpsertResult> {
|
||||
fn commit_plan_in_tx(db: &WriteDb, plan: ImportPlan) -> Result<BatchUpsertResult> {
|
||||
let items: BTreeMap<String, ImportPlanItem> =
|
||||
plan.items.iter().map(|item| (item.model_id.clone(), item.clone())).collect();
|
||||
|
||||
@@ -460,7 +460,7 @@ fn commit_plan_in_tx(db: &ClientDb, plan: ImportPlan) -> Result<BatchUpsertResul
|
||||
}
|
||||
|
||||
/// A folder deletion may have already cascaded over the model, so absent models are skipped.
|
||||
fn delete_existing_model(db: &ClientDb, resource: ImportResourceType, id: &str) -> Result<()> {
|
||||
fn delete_existing_model(db: &WriteDb, resource: ImportResourceType, id: &str) -> Result<()> {
|
||||
use ImportResourceType::*;
|
||||
let source = &UpdateSource::Import;
|
||||
match resource {
|
||||
@@ -503,7 +503,7 @@ fn delete_existing_model(db: &ClientDb, resource: ImportResourceType, id: &str)
|
||||
/// offered again next time. A resource the user turned down is remembered as a row without a
|
||||
/// model, so it is neither re-offered nor resurrected.
|
||||
fn record_import_source(
|
||||
db: &ClientDb,
|
||||
db: &WriteDb,
|
||||
plan: &ImportPlan,
|
||||
items: &BTreeMap<String, ImportPlanItem>,
|
||||
upserted: &BatchUpsertResult,
|
||||
@@ -521,16 +521,11 @@ fn record_import_source(
|
||||
};
|
||||
|
||||
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
|
||||
let existing = match resolve_linked_source(
|
||||
db,
|
||||
&workspace_id,
|
||||
&plan.importer,
|
||||
origin,
|
||||
&incoming_keys,
|
||||
)? {
|
||||
LinkedSource::Linked(source) => Some(source),
|
||||
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
|
||||
};
|
||||
let existing =
|
||||
match resolve_linked_source(db, &workspace_id, &plan.importer, origin, &incoming_keys)? {
|
||||
LinkedSource::Linked(source) => Some(source),
|
||||
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
|
||||
};
|
||||
let import_source = db.upsert_import_source(
|
||||
&ImportSource {
|
||||
id: existing.map(|s| s.id).unwrap_or_default(),
|
||||
@@ -659,9 +654,8 @@ fn resolve_linked_source(
|
||||
incoming_keys: &BTreeSet<String>,
|
||||
) -> Result<LinkedSource> {
|
||||
let sources = db.list_import_sources(workspace_id)?;
|
||||
let same_origin = |source: &ImportSource| {
|
||||
source.importer == importer && source.origin == origin.origin
|
||||
};
|
||||
let same_origin =
|
||||
|source: &ImportSource| source.importer == importer && source.origin == origin.origin;
|
||||
|
||||
let mut overlapping = Vec::new();
|
||||
for source in &sources {
|
||||
@@ -743,12 +737,18 @@ fn merge_with_linked_source(
|
||||
let mut current_models: BTreeMap<String, Value> = BTreeMap::new();
|
||||
{
|
||||
let mut consider = |planned_id: &str, resource: ImportResourceType| -> Result<()> {
|
||||
let Some(key) = plan.source_keys.get(planned_id) else { return Ok(()) };
|
||||
let Some(row) = rows.get(key) else { return Ok(()) };
|
||||
let Some(key) = plan.source_keys.get(planned_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(row) = rows.get(key) else {
|
||||
return Ok(());
|
||||
};
|
||||
if ImportResourceType::from_str(&row.model_type) != Some(resource) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(model_id) = row.model_id.as_deref() else { return Ok(()) };
|
||||
let Some(model_id) = row.model_id.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(current) = existing_model_json(&db, resource, model_id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -819,11 +819,9 @@ fn merge_with_linked_source(
|
||||
// environment: it came from this source, so the imported-copy separation does not apply.
|
||||
let mut restored_base_names = BTreeSet::new();
|
||||
for (i, v) in plan.resources.environments.iter_mut().enumerate() {
|
||||
let is_current_base = current_models
|
||||
.get(&v.id)
|
||||
.and_then(|m| m.get("parentModel"))
|
||||
.and_then(|p| p.as_str())
|
||||
== Some("workspace");
|
||||
let is_current_base =
|
||||
current_models.get(&v.id).and_then(|m| m.get("parentModel")).and_then(|p| p.as_str())
|
||||
== Some("workspace");
|
||||
if !is_current_base {
|
||||
continue;
|
||||
}
|
||||
@@ -936,16 +934,13 @@ fn merge_with_linked_source(
|
||||
(false, false) => (ImportPlanAction::Unchanged, false, None),
|
||||
(true, false) => (ImportPlanAction::Update, true, None),
|
||||
(false, true) => (ImportPlanAction::KeepLocal, false, None),
|
||||
(true, true) => (
|
||||
ImportPlanAction::Conflict,
|
||||
true,
|
||||
Some(ImportConflictResolution::KeepMine),
|
||||
),
|
||||
(true, true) => {
|
||||
(ImportPlanAction::Conflict, true, Some(ImportConflictResolution::KeepMine))
|
||||
}
|
||||
};
|
||||
// A resource the source moved into a folder that isn't imported keeps its place
|
||||
// until that folder is: nothing can be written into a folder that will not exist.
|
||||
let reason =
|
||||
(!reachable).then_some(ImportPlanReason::MovedIntoIgnoredFolder);
|
||||
let reason = (!reachable).then_some(ImportPlanReason::MovedIntoIgnoredFolder);
|
||||
let mut planned = item(action, selected && reachable, resolution, reason);
|
||||
planned.changed_fields = changed_fields(&incoming, ¤t);
|
||||
items.push(planned);
|
||||
@@ -956,16 +951,32 @@ fn merge_with_linked_source(
|
||||
classify(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone())?;
|
||||
}
|
||||
for v in &plan.resources.http_requests {
|
||||
classify(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone())?;
|
||||
classify(
|
||||
AnyModel::HttpRequest(v.clone()),
|
||||
ImportResourceType::HttpRequest,
|
||||
v.folder_id.clone(),
|
||||
)?;
|
||||
}
|
||||
for v in &plan.resources.grpc_requests {
|
||||
classify(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone())?;
|
||||
classify(
|
||||
AnyModel::GrpcRequest(v.clone()),
|
||||
ImportResourceType::GrpcRequest,
|
||||
v.folder_id.clone(),
|
||||
)?;
|
||||
}
|
||||
for v in &plan.resources.websocket_requests {
|
||||
classify(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone())?;
|
||||
classify(
|
||||
AnyModel::WebsocketRequest(v.clone()),
|
||||
ImportResourceType::WebsocketRequest,
|
||||
v.folder_id.clone(),
|
||||
)?;
|
||||
}
|
||||
for v in &plan.resources.environments {
|
||||
classify(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone())?;
|
||||
classify(
|
||||
AnyModel::Environment(v.clone()),
|
||||
ImportResourceType::Environment,
|
||||
v.parent_id.clone(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,16 +1041,32 @@ fn create_only_items(plan: &ImportPlan) -> Vec<ImportPlanItem> {
|
||||
push(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone());
|
||||
}
|
||||
for v in &plan.resources.http_requests {
|
||||
push(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone());
|
||||
push(
|
||||
AnyModel::HttpRequest(v.clone()),
|
||||
ImportResourceType::HttpRequest,
|
||||
v.folder_id.clone(),
|
||||
);
|
||||
}
|
||||
for v in &plan.resources.grpc_requests {
|
||||
push(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone());
|
||||
push(
|
||||
AnyModel::GrpcRequest(v.clone()),
|
||||
ImportResourceType::GrpcRequest,
|
||||
v.folder_id.clone(),
|
||||
);
|
||||
}
|
||||
for v in &plan.resources.websocket_requests {
|
||||
push(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone());
|
||||
push(
|
||||
AnyModel::WebsocketRequest(v.clone()),
|
||||
ImportResourceType::WebsocketRequest,
|
||||
v.folder_id.clone(),
|
||||
);
|
||||
}
|
||||
for v in &plan.resources.environments {
|
||||
push(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone());
|
||||
push(
|
||||
AnyModel::Environment(v.clone()),
|
||||
ImportResourceType::Environment,
|
||||
v.parent_id.clone(),
|
||||
);
|
||||
}
|
||||
items
|
||||
}
|
||||
@@ -1060,7 +1087,14 @@ enum KeyStatus<'a> {
|
||||
fn comparable(value: Value) -> Value {
|
||||
let mut value = strip_ids(value);
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
for field in ["model", "workspaceId", "createdAt", "updatedAt", "base", "sortPriority"] {
|
||||
for field in [
|
||||
"model",
|
||||
"workspaceId",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"base",
|
||||
"sortPriority",
|
||||
] {
|
||||
object.remove(field);
|
||||
}
|
||||
}
|
||||
@@ -1208,10 +1242,7 @@ fn validate_plan(plan: &ImportPlan) -> Result<()> {
|
||||
let updates_own_base = |id: &str| {
|
||||
plan.items.iter().any(|i| {
|
||||
i.model_id == id
|
||||
&& !matches!(
|
||||
i.action,
|
||||
ImportPlanAction::Create | ImportPlanAction::Ignored
|
||||
)
|
||||
&& !matches!(i.action, ImportPlanAction::Create | ImportPlanAction::Ignored)
|
||||
})
|
||||
};
|
||||
if plan
|
||||
@@ -1376,9 +1407,8 @@ fn assign_source_keys(
|
||||
for (i, v) in resources.environments.iter().enumerate() {
|
||||
let source = original.environments.get(i);
|
||||
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
|
||||
let parent_id = source.and_then(|s| {
|
||||
if s.parent_model == "folder" { s.parent_id.as_deref() } else { None }
|
||||
});
|
||||
let parent_id = source
|
||||
.and_then(|s| if s.parent_model == "folder" { s.parent_id.as_deref() } else { None });
|
||||
let ancestry = ancestry_path(&folder_tree, parent_id);
|
||||
let key = fallback_key("environment", &ancestry, name);
|
||||
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
|
||||
@@ -1386,8 +1416,7 @@ fn assign_source_keys(
|
||||
for (i, v) in resources.folders.iter().enumerate() {
|
||||
let source = original.folders.get(i);
|
||||
let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str());
|
||||
let ancestry =
|
||||
ancestry_path(&folder_tree, source.and_then(|s| s.folder_id.as_deref()));
|
||||
let ancestry = ancestry_path(&folder_tree, source.and_then(|s| s.folder_id.as_deref()));
|
||||
let key = fallback_key("folder", &ancestry, name);
|
||||
candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key));
|
||||
}
|
||||
@@ -1590,32 +1619,30 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
destination = db.upsert_workspace(&destination, &UpdateSource::Import)?;
|
||||
db.upsert_folder(&selected_folder, &UpdateSource::Import)?;
|
||||
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("seed destination");
|
||||
let workspace_count = query_manager.connect().list_workspaces().expect("list").len();
|
||||
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
@@ -1633,13 +1660,10 @@ mod tests {
|
||||
// Planning performed only reads.
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
|
||||
assert_eq!(db.list_workspaces().expect("list workspaces").len(), workspace_count);
|
||||
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.list_environments(&destination.id).expect("list environments").len(), 1);
|
||||
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
|
||||
}
|
||||
|
||||
@@ -1832,8 +1856,7 @@ mod tests {
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let destination = destination_workspace();
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(&destination, &UpdateSource::Import)
|
||||
.with_tx(|tx| tx.upsert_workspace(&destination, &UpdateSource::Import))
|
||||
.expect("create destination");
|
||||
let resources = ImportResources {
|
||||
workspaces: vec![
|
||||
@@ -2218,10 +2241,7 @@ mod tests {
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(db.list_http_requests(&workspace_id).expect("list requests").len(), 2);
|
||||
assert_eq!(db.list_folders(&workspace_id).expect("list folders").len(), 1);
|
||||
assert_eq!(
|
||||
db.list_environments_ensure_base(&workspace_id).expect("list environments").len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(db.list_environments(&workspace_id).expect("list environments").len(), 1);
|
||||
let rows = db.list_import_source_resources(&source.id).expect("list resource rows");
|
||||
assert_eq!(rows.len(), 4, "re-commit replaces rows instead of accumulating");
|
||||
}
|
||||
@@ -2240,23 +2260,22 @@ mod tests {
|
||||
.id
|
||||
.clone();
|
||||
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
let nested = db
|
||||
.list_http_requests(&workspace_id)
|
||||
.expect("list requests")
|
||||
.into_iter()
|
||||
.find(|r| r.name == "Nested Request")
|
||||
.expect("nested request");
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
url: "https://example.com/nested-local".to_string(),
|
||||
..nested.clone()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let nested = db
|
||||
.list_http_requests(&workspace_id)?
|
||||
.into_iter()
|
||||
.find(|r| r.name == "Nested Request")
|
||||
.expect("nested request");
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
url: "https://example.com/nested-local".to_string(),
|
||||
..nested.clone()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
})
|
||||
.expect("edit nested request locally");
|
||||
}
|
||||
|
||||
let mut resources = imported_resources();
|
||||
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
|
||||
@@ -2341,15 +2360,15 @@ mod tests {
|
||||
.id
|
||||
.clone();
|
||||
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
let root = db.get_http_request(&root_id).expect("get root");
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let root = db.get_http_request(&root_id)?;
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
})
|
||||
.expect("edit root locally");
|
||||
}
|
||||
|
||||
let mut resources = imported_resources();
|
||||
resources.http_requests[0].url = "https://example.com/root-v2".to_string();
|
||||
@@ -2478,8 +2497,7 @@ mod tests {
|
||||
.clone();
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.delete_http_request_by_id(&root_id, &UpdateSource::Background)
|
||||
.with_tx(|tx| tx.delete_http_request_by_id(&root_id, &UpdateSource::Background))
|
||||
.expect("delete root locally");
|
||||
|
||||
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
||||
@@ -2602,10 +2620,9 @@ mod tests {
|
||||
|
||||
// Anything new inside that folder can't be created either, so it waits for the folder.
|
||||
let mut resources = with_extra_folder(true);
|
||||
resources.http_requests.push(HttpRequest {
|
||||
folder_id: Some("fl_extra".to_string()),
|
||||
..extra_request()
|
||||
});
|
||||
resources
|
||||
.http_requests
|
||||
.push(HttpRequest { folder_id: Some("fl_extra".to_string()), ..extra_request() });
|
||||
let plan = replan(&query_manager, &workspace_id, resources);
|
||||
let extra = item_by_name(&plan, "Extra Request");
|
||||
assert_eq!(extra.action, ImportPlanAction::Create);
|
||||
@@ -2675,10 +2692,9 @@ mod tests {
|
||||
let workspace_id = committed.workspaces[0].id.clone();
|
||||
|
||||
// A second source claiming the same keys leaves nothing to merge into safely.
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
let other = db
|
||||
.upsert_import_source(
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let other = db.upsert_import_source(
|
||||
&ImportSource {
|
||||
workspace_id: workspace_id.clone(),
|
||||
importer: "OpenAPI".to_string(),
|
||||
@@ -2687,18 +2703,18 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
.expect("create second source");
|
||||
for key in ["env:base", "folder:src", "op:root", "op:nested"] {
|
||||
db.upsert_import_source_resource(&ImportSourceResource {
|
||||
import_source_id: other.id.clone(),
|
||||
source_key: key.to_string(),
|
||||
model_type: "http_request".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("claim the same keys");
|
||||
}
|
||||
}
|
||||
)?;
|
||||
for key in ["env:base", "folder:src", "op:root", "op:nested"] {
|
||||
db.upsert_import_source_resource(&ImportSourceResource {
|
||||
import_source_id: other.id.clone(),
|
||||
source_key: key.to_string(),
|
||||
model_type: "http_request".to_string(),
|
||||
..Default::default()
|
||||
})?;
|
||||
}
|
||||
Ok::<_, yaak_models::error::Error>(())
|
||||
})
|
||||
.expect("claim the same keys");
|
||||
|
||||
let third =
|
||||
ImportOrigin { origin: "/tmp/third.yaml".to_string(), label: "third.yaml".to_string() };
|
||||
@@ -2793,21 +2809,20 @@ mod tests {
|
||||
let committed = first_import(&query_manager);
|
||||
let workspace_id = committed.workspaces[0].id.clone();
|
||||
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
let sources = db.list_import_sources(&workspace_id).expect("list import sources");
|
||||
let row = db
|
||||
.list_import_source_resources(&sources[0].id)
|
||||
.expect("list rows")
|
||||
.into_iter()
|
||||
.find(|r| r.source_key == "op:root")
|
||||
.expect("row for the root request");
|
||||
db.upsert_import_source_resource(&ImportSourceResource {
|
||||
content_hash: Some("v99:from-the-future".to_string()),
|
||||
..row
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let sources = db.list_import_sources(&workspace_id)?;
|
||||
let row = db
|
||||
.list_import_source_resources(&sources[0].id)?
|
||||
.into_iter()
|
||||
.find(|r| r.source_key == "op:root")
|
||||
.expect("row for the root request");
|
||||
db.upsert_import_source_resource(&ImportSourceResource {
|
||||
content_hash: Some("v99:from-the-future".to_string()),
|
||||
..row
|
||||
})
|
||||
})
|
||||
.expect("write an unreadable hash");
|
||||
}
|
||||
|
||||
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
||||
assert_eq!(
|
||||
@@ -2846,17 +2861,20 @@ mod tests {
|
||||
.clone();
|
||||
|
||||
// Opening the request in the editor stamps a row ID onto every header it renders.
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
let root = db.get_http_request(&root_id).expect("get root");
|
||||
let headers = root
|
||||
.headers
|
||||
.iter()
|
||||
.map(|h| HttpRequestHeader { id: Some("hd_generated".to_string()), ..h.clone() })
|
||||
.collect();
|
||||
db.upsert_http_request(&HttpRequest { headers, ..root }, &UpdateSource::Background)
|
||||
.expect("stamp row ids");
|
||||
}
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let root = db.get_http_request(&root_id)?;
|
||||
let headers = root
|
||||
.headers
|
||||
.iter()
|
||||
.map(|h| HttpRequestHeader {
|
||||
id: Some("hd_generated".to_string()),
|
||||
..h.clone()
|
||||
})
|
||||
.collect();
|
||||
db.upsert_http_request(&HttpRequest { headers, ..root }, &UpdateSource::Background)
|
||||
})
|
||||
.expect("stamp row ids");
|
||||
|
||||
let plan = replan(&query_manager, &workspace_id, resources);
|
||||
assert_eq!(
|
||||
@@ -2930,73 +2948,72 @@ mod tests {
|
||||
assert_eq!(not_wanted, 2, "the skipped folder and its request are remembered: {rows:?}");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn desktop_style_json_roundtrip_records_source() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"OpenAPI".to_string(),
|
||||
ImportDestination::NewWorkspace,
|
||||
imported_resources(),
|
||||
None,
|
||||
Some(linked_origin()),
|
||||
)
|
||||
.expect("plan import");
|
||||
let json = serde_json::to_string(&plan).expect("serialize plan");
|
||||
let plan: ImportPlan = serde_json::from_str(&json).expect("deserialize plan");
|
||||
let committed = commit_import_plan(&query_manager, plan).expect("commit");
|
||||
let workspace_id = committed.workspaces[0].id.clone();
|
||||
let source = query_manager
|
||||
.connect()
|
||||
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
|
||||
.expect("query")
|
||||
.expect("source recorded after JSON round-trip");
|
||||
assert_eq!(source.origin_label, "api.yaml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_keep_local_reverts_the_local_edit() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let committed = first_import(&query_manager);
|
||||
let workspace_id = committed.workspaces[0].id.clone();
|
||||
let root_id = committed
|
||||
.http_requests
|
||||
.iter()
|
||||
.find(|r| r.name == "Root Request")
|
||||
.expect("root request")
|
||||
.id
|
||||
.clone();
|
||||
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
let root = db.get_http_request(&root_id).expect("get root");
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
|
||||
&UpdateSource::Background,
|
||||
#[test]
|
||||
fn desktop_style_json_roundtrip_records_source() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"OpenAPI".to_string(),
|
||||
ImportDestination::NewWorkspace,
|
||||
imported_resources(),
|
||||
None,
|
||||
Some(linked_origin()),
|
||||
)
|
||||
.expect("edit root locally");
|
||||
.expect("plan import");
|
||||
let json = serde_json::to_string(&plan).expect("serialize plan");
|
||||
let plan: ImportPlan = serde_json::from_str(&json).expect("deserialize plan");
|
||||
let committed = commit_import_plan(&query_manager, plan).expect("commit");
|
||||
let workspace_id = committed.workspaces[0].id.clone();
|
||||
let source = query_manager
|
||||
.connect()
|
||||
.find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml")
|
||||
.expect("query")
|
||||
.expect("source recorded after JSON round-trip");
|
||||
assert_eq!(source.origin_label, "api.yaml");
|
||||
}
|
||||
|
||||
let mut plan = replan(&query_manager, &workspace_id, imported_resources());
|
||||
let root = item_by_name(&plan, "Root Request");
|
||||
assert_eq!(root.action, ImportPlanAction::KeepLocal);
|
||||
assert!(!root.selected, "keep-local defaults to keeping the local edit");
|
||||
for item in plan.items.iter_mut() {
|
||||
if item.action == ImportPlanAction::KeepLocal {
|
||||
item.selected = true;
|
||||
#[test]
|
||||
fn selected_keep_local_reverts_the_local_edit() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let committed = first_import(&query_manager);
|
||||
let workspace_id = committed.workspaces[0].id.clone();
|
||||
let root_id = committed
|
||||
.http_requests
|
||||
.iter()
|
||||
.find(|r| r.name == "Root Request")
|
||||
.expect("root request")
|
||||
.id
|
||||
.clone();
|
||||
|
||||
query_manager
|
||||
.with_tx(|db| {
|
||||
let root = db.get_http_request(&root_id)?;
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/root-local".to_string(), ..root },
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
})
|
||||
.expect("edit root locally");
|
||||
|
||||
let mut plan = replan(&query_manager, &workspace_id, imported_resources());
|
||||
let root = item_by_name(&plan, "Root Request");
|
||||
assert_eq!(root.action, ImportPlanAction::KeepLocal);
|
||||
assert!(!root.selected, "keep-local defaults to keeping the local edit");
|
||||
for item in plan.items.iter_mut() {
|
||||
if item.action == ImportPlanAction::KeepLocal {
|
||||
item.selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
commit_import_plan(&query_manager, plan).expect("commit revert");
|
||||
commit_import_plan(&query_manager, plan).expect("commit revert");
|
||||
|
||||
assert_eq!(
|
||||
query_manager.connect().get_http_request(&root_id).expect("get root").url,
|
||||
"https://example.com/root",
|
||||
"selected keep-local must revert to the source version"
|
||||
);
|
||||
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
||||
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged);
|
||||
}
|
||||
assert_eq!(
|
||||
query_manager.connect().get_http_request(&root_id).expect("get root").url,
|
||||
"https://example.com/root",
|
||||
"selected keep-local must revert to the source version"
|
||||
);
|
||||
let plan = replan(&query_manager, &workspace_id, imported_resources());
|
||||
assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::response_body::ResponseBodyStore;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use log::warn;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
@@ -226,11 +227,18 @@ fn build_shared_reply(
|
||||
InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })
|
||||
}
|
||||
SharedRequest::SetKeyValue(req) => {
|
||||
query_manager.connect().set_plugin_key_value(context.plugin_name, &req.key, &req.value);
|
||||
if let Err(e) = query_manager.with_tx(|tx| {
|
||||
tx.set_plugin_key_value(context.plugin_name, &req.key, &req.value);
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
}) {
|
||||
warn!("Failed to set plugin key value: {e}");
|
||||
}
|
||||
InternalEventPayload::SetKeyValueResponse(yaak_plugins::events::SetKeyValueResponse {})
|
||||
}
|
||||
SharedRequest::DeleteKeyValue(req) => {
|
||||
match query_manager.connect().delete_plugin_key_value(context.plugin_name, &req.key) {
|
||||
match query_manager
|
||||
.with_tx(|tx| tx.delete_plugin_key_value(context.plugin_name, &req.key))
|
||||
{
|
||||
Ok(deleted) => {
|
||||
InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted })
|
||||
}
|
||||
@@ -331,7 +339,9 @@ fn build_shared_reply(
|
||||
|
||||
let model = match &req.model {
|
||||
HttpRequest(m) => {
|
||||
match query_manager.connect().upsert_http_request(m, &UpdateSource::Plugin) {
|
||||
match query_manager
|
||||
.with_tx(|tx| tx.upsert_http_request(m, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => HttpRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
@@ -341,7 +351,9 @@ fn build_shared_reply(
|
||||
}
|
||||
}
|
||||
GrpcRequest(m) => {
|
||||
match query_manager.connect().upsert_grpc_request(m, &UpdateSource::Plugin) {
|
||||
match query_manager
|
||||
.with_tx(|tx| tx.upsert_grpc_request(m, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => GrpcRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
@@ -351,7 +363,8 @@ fn build_shared_reply(
|
||||
}
|
||||
}
|
||||
WebsocketRequest(m) => {
|
||||
match query_manager.connect().upsert_websocket_request(m, &UpdateSource::Plugin)
|
||||
match query_manager
|
||||
.with_tx(|tx| tx.upsert_websocket_request(m, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => WebsocketRequest(model),
|
||||
Err(err) => {
|
||||
@@ -362,7 +375,7 @@ fn build_shared_reply(
|
||||
}
|
||||
}
|
||||
Folder(m) => {
|
||||
match query_manager.connect().upsert_folder(m, &UpdateSource::Plugin) {
|
||||
match query_manager.with_tx(|tx| tx.upsert_folder(m, &UpdateSource::Plugin)) {
|
||||
Ok(model) => Folder(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
@@ -372,7 +385,9 @@ fn build_shared_reply(
|
||||
}
|
||||
}
|
||||
Environment(m) => {
|
||||
match query_manager.connect().upsert_environment(m, &UpdateSource::Plugin) {
|
||||
match query_manager
|
||||
.with_tx(|tx| tx.upsert_environment(m, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => Environment(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
@@ -382,7 +397,8 @@ fn build_shared_reply(
|
||||
}
|
||||
}
|
||||
Workspace(m) => {
|
||||
match query_manager.connect().upsert_workspace(m, &UpdateSource::Plugin) {
|
||||
match query_manager.with_tx(|tx| tx.upsert_workspace(m, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => Workspace(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
@@ -404,8 +420,7 @@ fn build_shared_reply(
|
||||
let model = match req.model.as_str() {
|
||||
"http_request" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_http_request_by_id(&req.id, &UpdateSource::Plugin)
|
||||
.with_tx(|tx| tx.delete_http_request_by_id(&req.id, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => AnyModel::HttpRequest(model),
|
||||
Err(err) => {
|
||||
@@ -417,8 +432,7 @@ fn build_shared_reply(
|
||||
}
|
||||
"grpc_request" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)
|
||||
.with_tx(|tx| tx.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => AnyModel::GrpcRequest(model),
|
||||
Err(err) => {
|
||||
@@ -429,10 +443,9 @@ fn build_shared_reply(
|
||||
}
|
||||
}
|
||||
"websocket_request" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)
|
||||
{
|
||||
match query_manager.with_tx(|tx| {
|
||||
tx.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)
|
||||
}) {
|
||||
Ok(model) => AnyModel::WebsocketRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
@@ -442,8 +455,7 @@ fn build_shared_reply(
|
||||
}
|
||||
}
|
||||
"folder" => match query_manager
|
||||
.connect()
|
||||
.delete_folder_by_id(&req.id, &UpdateSource::Plugin)
|
||||
.with_tx(|tx| tx.delete_folder_by_id(&req.id, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => AnyModel::Folder(model),
|
||||
Err(err) => {
|
||||
@@ -454,8 +466,7 @@ fn build_shared_reply(
|
||||
},
|
||||
"environment" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_environment_by_id(&req.id, &UpdateSource::Plugin)
|
||||
.with_tx(|tx| tx.delete_environment_by_id(&req.id, &UpdateSource::Plugin))
|
||||
{
|
||||
Ok(model) => AnyModel::Environment(model),
|
||||
Err(err) => {
|
||||
@@ -508,45 +519,38 @@ mod tests {
|
||||
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB");
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(
|
||||
&Workspace {
|
||||
id: "wk_test".to_string(),
|
||||
name: "Workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to seed workspace");
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_folder(
|
||||
&Folder {
|
||||
id: "fl_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
name: "Folder".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to seed folder");
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
id: "rq_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
folder_id: Some("fl_test".to_string()),
|
||||
name: "Request".to_string(),
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to seed request");
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace {
|
||||
id: "wk_test".to_string(),
|
||||
name: "Workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)?;
|
||||
tx.upsert_folder(
|
||||
&Folder {
|
||||
id: "fl_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
name: "Folder".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)?;
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest {
|
||||
id: "rq_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
folder_id: Some("fl_test".to_string()),
|
||||
name: "Request".to_string(),
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed");
|
||||
|
||||
(query_manager, temp_dir)
|
||||
}
|
||||
@@ -668,9 +672,10 @@ mod tests {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let store = FakeBodyStore { body: b"hello".to_vec(), reads: RefCell::new(Vec::new()) };
|
||||
|
||||
let info_payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_test".to_string() },
|
||||
);
|
||||
let info_payload =
|
||||
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
|
||||
response_id: "rs_test".to_string(),
|
||||
});
|
||||
let info = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&store,
|
||||
@@ -716,9 +721,10 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unreadable_response_body_becomes_an_error_reply() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_never_persisted".to_string() },
|
||||
);
|
||||
let payload =
|
||||
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
|
||||
response_id: "rs_never_persisted".to_string(),
|
||||
});
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
@@ -727,7 +733,11 @@ mod tests {
|
||||
|
||||
match result {
|
||||
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
|
||||
assert!(resp.error.contains("rs_never_persisted"), "unhelpful error: {}", resp.error)
|
||||
assert!(
|
||||
resp.error.contains("rs_never_persisted"),
|
||||
"unhelpful error: {}",
|
||||
resp.error
|
||||
)
|
||||
}
|
||||
other => panic!("unexpected missing-response result: {other:?}"),
|
||||
}
|
||||
|
||||
@@ -126,23 +126,25 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
id: "rq_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_request(
|
||||
&HttpRequest {
|
||||
id: "rq_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let body_path = body.map(|bytes| {
|
||||
@@ -153,21 +155,22 @@ mod tests {
|
||||
});
|
||||
|
||||
let response = query_manager
|
||||
.connect()
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
request_id: "rq_test".to_string(),
|
||||
body_path,
|
||||
headers: vec![HttpResponseHeader {
|
||||
name: "Content-Type".to_string(),
|
||||
value: "application/json; charset=utf-8".to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
&blob_manager,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&HttpResponse {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
request_id: "rq_test".to_string(),
|
||||
body_path,
|
||||
headers: vec![HttpResponseHeader {
|
||||
name: "Content-Type".to_string(),
|
||||
value: "application/json; charset=utf-8".to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
&blob_manager,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let id = response.id.clone();
|
||||
@@ -209,7 +212,7 @@ mod tests {
|
||||
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
qm.connect().update_http_response_if_id(&response, &UpdateSource::Sync).unwrap();
|
||||
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
|
||||
|
||||
assert!(FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
|
||||
}
|
||||
|
||||
+77
-69
@@ -646,8 +646,9 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
if let Some(store) = store {
|
||||
response = store
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(&response, &store.update_source, store.blob_manager)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(&response, &store.update_source, store.blob_manager)
|
||||
})
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
} else if response.id.is_empty() {
|
||||
response.id = generate_prefixed_id("rs");
|
||||
@@ -700,8 +701,8 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
&event_workspace_id,
|
||||
event.clone().into(),
|
||||
);
|
||||
if let Err(err) =
|
||||
query_manager.connect().upsert_http_response_event(&db_event, update_source)
|
||||
if let Err(err) = query_manager
|
||||
.with_tx(|tx| tx.upsert_http_response_event(&db_event, update_source))
|
||||
{
|
||||
warn!("Failed to persist HTTP response event: {}", err);
|
||||
}
|
||||
@@ -799,8 +800,13 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
if let Some(store) = store {
|
||||
response = store
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(&connected_response, &store.update_source, store.blob_manager)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&connected_response,
|
||||
&store.update_source,
|
||||
store.blob_manager,
|
||||
)
|
||||
})
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
} else {
|
||||
response = connected_response;
|
||||
@@ -886,12 +892,13 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
if let Some(store) = store {
|
||||
response = store
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(
|
||||
&progress_response,
|
||||
&store.update_source,
|
||||
store.blob_manager,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&progress_response,
|
||||
&store.update_source,
|
||||
store.blob_manager,
|
||||
)
|
||||
})
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
} else {
|
||||
response = progress_response;
|
||||
@@ -960,8 +967,9 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
if let Some(store) = store {
|
||||
response = store
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(&final_response, &store.update_source, store.blob_manager)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(&final_response, &store.update_source, store.blob_manager)
|
||||
})
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
} else {
|
||||
response = final_response;
|
||||
@@ -998,8 +1006,9 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
if update_response && let Some(store) = store {
|
||||
response = store
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(&response, &store.update_source, store.blob_manager)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(&response, &store.update_source, store.blob_manager)
|
||||
})
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
}
|
||||
}
|
||||
@@ -1027,17 +1036,14 @@ fn persist_request_body_bytes(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let blob_ctx = blob_manager.connect();
|
||||
let mut offset = 0;
|
||||
let mut chunk_index: i32 = 0;
|
||||
while offset < bytes.len() {
|
||||
let end = std::cmp::min(offset + REQUEST_BODY_CHUNK_SIZE, bytes.len());
|
||||
let chunk = BodyChunk::new(body_id, chunk_index, bytes[offset..end].to_vec());
|
||||
blob_ctx.insert_chunk(&chunk).map_err(|e| e.to_string())?;
|
||||
chunk_index += 1;
|
||||
offset = end;
|
||||
}
|
||||
Ok(())
|
||||
blob_manager
|
||||
.with_tx(|b| {
|
||||
for (chunk_index, data) in bytes.chunks(REQUEST_BODY_CHUNK_SIZE).enumerate() {
|
||||
b.insert_chunk(&BodyChunk::new(body_id, chunk_index as i32, data.to_vec()))?;
|
||||
}
|
||||
Ok::<_, yaak_models::error::Error>(())
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn persist_request_body_stream(
|
||||
@@ -1057,14 +1063,14 @@ async fn persist_request_body_stream(
|
||||
while buf.len() >= REQUEST_BODY_CHUNK_SIZE {
|
||||
let data = buf.drain(..REQUEST_BODY_CHUNK_SIZE).collect();
|
||||
let chunk = BodyChunk::new(&body_id, chunk_index, data);
|
||||
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
|
||||
blob_manager.with_tx(|b| b.insert_chunk(&chunk)).map_err(|e| e.to_string())?;
|
||||
chunk_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !buf.is_empty() {
|
||||
let chunk = BodyChunk::new(&body_id, chunk_index, buf);
|
||||
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
|
||||
blob_manager.with_tx(|b| b.insert_chunk(&chunk)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(total_bytes)
|
||||
@@ -1114,8 +1120,7 @@ pub fn persist_cookies_after_send(
|
||||
|
||||
cookie_jar.cookies = cookies;
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_cookie_jar(cookie_jar, &UpdateSource::Background)
|
||||
.with_tx(|tx| tx.upsert_cookie_jar(cookie_jar, &UpdateSource::Background))
|
||||
.map_err(SendHttpRequestError::PersistCookieJar)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1209,23 +1214,24 @@ fn persist_response_error(
|
||||
let elapsed = duration_to_i32(started_at.elapsed());
|
||||
store
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
state: HttpResponseState::Closed,
|
||||
elapsed,
|
||||
elapsed_headers: if response.elapsed_headers == 0 {
|
||||
elapsed
|
||||
} else {
|
||||
response.elapsed_headers
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&HttpResponse {
|
||||
state: HttpResponseState::Closed,
|
||||
elapsed,
|
||||
elapsed_headers: if response.elapsed_headers == 0 {
|
||||
elapsed
|
||||
} else {
|
||||
response.elapsed_headers
|
||||
},
|
||||
error: Some(error),
|
||||
url: if response.url.is_empty() { fallback_url } else { response.url.clone() },
|
||||
..response.clone()
|
||||
},
|
||||
error: Some(error),
|
||||
url: if response.url.is_empty() { fallback_url } else { response.url.clone() },
|
||||
..response.clone()
|
||||
},
|
||||
&store.update_source,
|
||||
store.blob_manager,
|
||||
)
|
||||
&store.update_source,
|
||||
store.blob_manager,
|
||||
)
|
||||
})
|
||||
.map_err(SendHttpRequestError::PersistResponse)
|
||||
}
|
||||
|
||||
@@ -1444,23 +1450,25 @@ mod tests {
|
||||
.expect("Failed to initialize DB");
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed workspace");
|
||||
let cookie_jar = query_manager
|
||||
.connect()
|
||||
.upsert_cookie_jar(
|
||||
&CookieJar {
|
||||
id: "cj_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
name: "Default".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_cookie_jar(
|
||||
&CookieJar {
|
||||
id: "cj_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
name: "Default".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to seed cookie jar");
|
||||
|
||||
(query_manager, cookie_jar, temp_dir)
|
||||
@@ -1507,18 +1515,18 @@ mod tests {
|
||||
let (query_manager, mut cookie_jar, _temp_dir) = seed_cookie_jar();
|
||||
cookie_jar.cookies = vec![cookie("original")];
|
||||
cookie_jar = query_manager
|
||||
.connect()
|
||||
.upsert_cookie_jar(&cookie_jar, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_cookie_jar(&cookie_jar, &UpdateSource::Sync))
|
||||
.expect("Failed to seed cookies");
|
||||
let store = CookieStore::from_cookies(cookie_jar.cookies.clone());
|
||||
|
||||
// Someone else updates the jar while the send is in flight.
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_cookie_jar(
|
||||
&CookieJar { cookies: vec![cookie("newer")], ..cookie_jar.clone() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_cookie_jar(
|
||||
&CookieJar { cookies: vec![cookie("newer")], ..cookie_jar.clone() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
})
|
||||
.expect("Failed to update cookie jar");
|
||||
|
||||
persist_cookies_after_send(&query_manager, Some(&mut cookie_jar), Some(&store))
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<li>Git-friendly plain-text project storage</li>
|
||||
<li>Environment variables and template functions</li>
|
||||
<li>Request chaining and dynamic values</li>
|
||||
<li>OAuth 2.0, Bearer, Basic, API Key, AWS, JWT, and NTLM authentication</li>
|
||||
<li>OAuth 2.0, Bearer, Basic, Digest, API Key, AWS, JWT, and NTLM authentication</li>
|
||||
<li>Import from cURL, Postman, Insomnia, and OpenAPI</li>
|
||||
<li>Extensible plugin system</li>
|
||||
</ul>
|
||||
|
||||
Generated
+768
-627
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -25,6 +25,7 @@
|
||||
"plugins/auth-aws",
|
||||
"plugins/auth-basic",
|
||||
"plugins/auth-bearer",
|
||||
"plugins/auth-digest",
|
||||
"plugins/auth-jwt",
|
||||
"plugins/auth-ntlm",
|
||||
"plugins/auth-oauth2",
|
||||
@@ -130,7 +131,7 @@
|
||||
"tar": "^7.5.22",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9",
|
||||
"vite-plus": "^0.3.0",
|
||||
"vitest": "^4.1.10",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
|
||||
@@ -76,7 +76,12 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
cmd_send_http_request: (payload, db) => {
|
||||
const requestId = str(payload, "requestId");
|
||||
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
|
||||
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
|
||||
return sendHttpRequest(
|
||||
db,
|
||||
requestId,
|
||||
str(payload, "environmentId"),
|
||||
str(payload, "cookieJarId"),
|
||||
);
|
||||
},
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
@@ -234,6 +239,7 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
|
||||
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
||||
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
||||
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
||||
{ name: "digest", label: "Digest Auth", shortLabel: "Digest" },
|
||||
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
||||
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
||||
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
|
||||
@@ -262,10 +268,16 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: [
|
||||
"WebSocket requests aren't available in the browser yet",
|
||||
"websocket",
|
||||
],
|
||||
|
||||
// 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_data: [
|
||||
"Importing from a file needs a filesystem, which a browser tab has no",
|
||||
"localFiles",
|
||||
],
|
||||
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
|
||||
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
||||
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
||||
@@ -298,8 +310,14 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_function_config: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_tokens_to_string: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
"@types/node": "^24.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpy-cli": "^5.0.0"
|
||||
"cpy-cli": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type AnyModel =
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
@@ -14,28 +14,35 @@ remembered for next time.
|
||||
|
||||
## Supported Languages
|
||||
|
||||
Each language supports one or more libraries:
|
||||
Each language supports one or more libraries, listed in the order they appear in the
|
||||
dialog:
|
||||
|
||||
| Language | Libraries |
|
||||
| ----------- | ------------------------------------ |
|
||||
| C | libcurl |
|
||||
| Clojure | clj-http |
|
||||
| C# | HttpClient, RestSharp |
|
||||
| Go | Native |
|
||||
| HTTP | HTTP/1.1 |
|
||||
| Java | AsyncHttp, NetHttp, OkHttp, Unirest |
|
||||
| JavaScript | Axios, fetch, jQuery, XHR |
|
||||
| Kotlin | OkHttp |
|
||||
| Node.js | Axios, fetch, HTTP, Request, Unirest |
|
||||
| Objective-C | NSURLSession |
|
||||
| OCaml | CoHTTP |
|
||||
| PHP | cURL, Guzzle, HTTP v1, HTTP v2 |
|
||||
| PowerShell | Invoke-WebRequest, RestMethod |
|
||||
| Python | http.client, Requests |
|
||||
| R | httr |
|
||||
| Ruby | Native |
|
||||
| Shell | cURL, HTTPie, Wget |
|
||||
| Swift | URLSession |
|
||||
| Language | Libraries |
|
||||
| ----------- | ----------------------------------------- |
|
||||
| Agent | Agent Prompt |
|
||||
| C | Libcurl |
|
||||
| Clojure | clj-http |
|
||||
| Crystal | http::client |
|
||||
| C# | HttpClient, RestSharp |
|
||||
| Go | NewRequest |
|
||||
| HTTP | HTTP/1.1 |
|
||||
| Java | AsyncHttp, java.net.http, OkHttp, Unirest |
|
||||
| JavaScript | XMLHttpRequest, Axios, fetch, jQuery |
|
||||
| Kotlin | OkHttp |
|
||||
| Node.js | HTTP, Axios, fetch |
|
||||
| Objective-C | NSURLSession |
|
||||
| OCaml | CoHTTP |
|
||||
| PHP | cURL, Guzzle, HTTP v1, HTTP v2 |
|
||||
| Powershell | Invoke-WebRequest, Invoke-RestMethod |
|
||||
| Python | Requests |
|
||||
| R | httr |
|
||||
| Ruby | net::http, faraday |
|
||||
| Rust | reqwest |
|
||||
| Shell | cURL, HTTPie, Wget |
|
||||
| Swift | URLSession |
|
||||
|
||||
The **Agent** target produces a prompt describing the request rather than code, for handing
|
||||
to an AI coding agent.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Digest Authentication
|
||||
|
||||
An HTTP Digest Authentication plugin that implements
|
||||
[RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616), with fallback to the
|
||||
older [RFC 2617](https://datatracker.ietf.org/doc/html/rfc2617) and
|
||||
[RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) behaviour that many
|
||||
servers still speak.
|
||||
|
||||
## Overview
|
||||
|
||||
Digest Authentication proves you know a password without ever putting it on the
|
||||
wire. The server issues a one-time `nonce`, and the client answers with a hash
|
||||
over the credentials, the nonce and the request itself.
|
||||
|
||||
## How it works
|
||||
|
||||
Because the digest is computed over a server-issued nonce, the challenge has to
|
||||
be fetched before the real request can be signed. On each send, the plugin:
|
||||
|
||||
1. Sends an unauthenticated probe of the same method and URL, carrying no
|
||||
headers of its own so it cannot authorize anything
|
||||
2. Reads the `WWW-Authenticate: Digest …` challenge from the `401` response
|
||||
3. Computes the response hash and returns the `Authorization` header
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Username**: Username or user identifier
|
||||
- **Password**: Password or authentication token
|
||||
- **Realm** (advanced): Only needed when the server offers more than one realm.
|
||||
Leave it empty and the first challenge the server prefers is used.
|
||||
|
||||
## Supported challenges
|
||||
|
||||
| Feature | Supported |
|
||||
| ---------- | ------------------------------------------------------------- |
|
||||
| Algorithm | `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess` |
|
||||
| `qop` | `auth`, `auth-int`, and challenges that omit `qop` (RFC 2069) |
|
||||
| `opaque` | Echoed back when the server sends one |
|
||||
| `userhash` | Declined with `userhash=false` |
|
||||
|
||||
`auth-int` is used when the server offers it and Yaak has the request body in
|
||||
hand. Bodies that are streamed from disk or above the size Yaak passes to auth
|
||||
plugins aren't available to hash, so those requests use `auth` instead.
|
||||
|
||||
Credentials are normalized to Unicode NFC before hashing, per RFC 7616 §4.
|
||||
Usernames outside ASCII are sent as an RFC 5987 extended value (`username*`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Server did not offer Digest authentication**: The endpoint answered the probe
|
||||
with a different scheme, or with no `WWW-Authenticate` header at all. Check the
|
||||
URL, and whether the endpoint requires auth in the first place.
|
||||
- **Unsupported Digest algorithm**: The server asked for an algorithm this plugin
|
||||
doesn't implement, such as `SHA-512-256`.
|
||||
- **401 Unauthorized**: Verify the username and password. If the server offers
|
||||
several realms, set the Realm field to the one your account belongs to.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@yaak/auth-digest",
|
||||
"displayName": "Digest Authentication",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Authenticate requests using HTTP Digest authentication",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mountain-loop/yaak.git",
|
||||
"directory": "plugins/auth-digest"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev",
|
||||
"test": "vp test --run tests"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** A single challenge from a `WWW-Authenticate` header. */
|
||||
export interface AuthChallenge {
|
||||
scheme: string;
|
||||
params: Record<string, string>;
|
||||
/** The `token68` form (`NTLM TlRMTVNT…`), which carries no parameters. */
|
||||
token68?: string;
|
||||
}
|
||||
|
||||
export interface DigestChallenge {
|
||||
realm: string;
|
||||
nonce: string;
|
||||
opaque?: string;
|
||||
qop?: string[];
|
||||
/** Echoed back verbatim, so it must keep the server's own spelling. */
|
||||
algorithm?: string;
|
||||
stale: boolean;
|
||||
userhash: boolean;
|
||||
}
|
||||
|
||||
export interface DigestAuthorizationOptions {
|
||||
username: string;
|
||||
password: string;
|
||||
method: string;
|
||||
uri: string;
|
||||
body: string | null;
|
||||
challenge: DigestChallenge;
|
||||
cnonce: string;
|
||||
nc: number;
|
||||
}
|
||||
|
||||
const TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+";
|
||||
const PARAM_RE = new RegExp(`^(${TOKEN})\\s*=\\s*([\\s\\S]*)$`);
|
||||
const SCHEME_RE = new RegExp(`^(${TOKEN})(?:\\s+([\\s\\S]*))?$`);
|
||||
const TOKEN68_RE = /^[A-Za-z0-9\-._~+/]+=*$/;
|
||||
|
||||
const SUPPORTED_ALGORITHMS = ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"];
|
||||
const SUPPORTED_QOPS = ["auth", "auth-int"];
|
||||
|
||||
/**
|
||||
* Split a header value on commas that aren't inside a quoted string. Both
|
||||
* challenges and their parameters are comma-separated, so this yields a flat
|
||||
* list that {@link parseChallenges} re-groups.
|
||||
*/
|
||||
function splitOnCommas(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i]!;
|
||||
if (quoted && char === "\\" && i + 1 < value.length) {
|
||||
current += char + value[++i]!;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
current += char;
|
||||
} else if (char === "," && !quoted) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
parts.push(current);
|
||||
|
||||
return parts.map((p) => p.trim()).filter((p) => p !== "");
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return trimmed.slice(1, -1).replace(/\\([\s\S])/g, "$1");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseChallenges(headerValues: string[]): AuthChallenge[] {
|
||||
const challenges: AuthChallenge[] = [];
|
||||
|
||||
for (const headerValue of headerValues) {
|
||||
let current: AuthChallenge | null = null;
|
||||
|
||||
for (const part of splitOnCommas(headerValue)) {
|
||||
const param = PARAM_RE.exec(part);
|
||||
if (param != null && current != null) {
|
||||
current.params[param[1]!.toLowerCase()] = unquote(param[2]!);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheme = SCHEME_RE.exec(part);
|
||||
if (scheme == null) continue;
|
||||
|
||||
current = { scheme: scheme[1]!, params: {} };
|
||||
challenges.push(current);
|
||||
|
||||
const rest = scheme[2]?.trim();
|
||||
if (rest == null || rest === "") continue;
|
||||
|
||||
if (TOKEN68_RE.test(rest)) {
|
||||
current.token68 = rest;
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstParam = PARAM_RE.exec(rest);
|
||||
if (firstParam != null) {
|
||||
current.params[firstParam[1]!.toLowerCase()] = unquote(firstParam[2]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return challenges;
|
||||
}
|
||||
|
||||
export function toDigestChallenge(params: Record<string, string>): DigestChallenge {
|
||||
const qop = params.qop
|
||||
?.split(",")
|
||||
.map((v) => v.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
realm: params.realm ?? "",
|
||||
nonce: params.nonce ?? "",
|
||||
opaque: params.opaque,
|
||||
qop: qop == null || qop.length === 0 ? undefined : qop,
|
||||
algorithm: params.algorithm,
|
||||
stale: params.stale?.toLowerCase() === "true",
|
||||
userhash: params.userhash?.toLowerCase() === "true",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `MD5`, `MD5-sess`, `SHA-256` and `SHA-256-sess`, tolerating the `SHA256`
|
||||
* spelling some servers use. Returns null for anything else.
|
||||
*/
|
||||
function resolveAlgorithm(algorithm: string | undefined): { hash: string; sess: boolean } | null {
|
||||
const value = (algorithm ?? "MD5").trim().toLowerCase();
|
||||
const sess = value.endsWith("-sess");
|
||||
const base = (sess ? value.slice(0, -"-sess".length) : value).replace(/-/g, "");
|
||||
if (base === "md5") return { hash: "md5", sess };
|
||||
if (base === "sha256") return { hash: "sha256", sess };
|
||||
return null;
|
||||
}
|
||||
|
||||
function unsupportedAlgorithmError(algorithm: string | undefined): Error {
|
||||
return new Error(
|
||||
`Unsupported Digest algorithm: ${algorithm ?? "MD5"}. ` +
|
||||
`Supported algorithms are ${SUPPORTED_ALGORITHMS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function unsupportedQopError(qop: string[]): Error {
|
||||
return new Error(
|
||||
`Unsupported Digest qop: ${qop.join(", ")}. Supported values are ${SUPPORTED_QOPS.join(" and ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that would stop this challenge from being answered, or null if it
|
||||
* can be. Selection asks the whole question at once so a challenge that fails
|
||||
* on any count is passed over for the next one the server offered, rather than
|
||||
* chosen and then failed on later.
|
||||
*/
|
||||
function challengeProblem(challenge: DigestChallenge): Error | null {
|
||||
if (resolveAlgorithm(challenge.algorithm) == null) {
|
||||
return unsupportedAlgorithmError(challenge.algorithm);
|
||||
}
|
||||
if (challenge.nonce === "") {
|
||||
return new Error('Digest challenge is missing the required "nonce" parameter');
|
||||
}
|
||||
if (challenge.qop != null && !challenge.qop.some((q) => SUPPORTED_QOPS.includes(q))) {
|
||||
return unsupportedQopError(challenge.qop);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the challenge to answer. Servers list challenges strongest-first
|
||||
* (RFC 7616 §3.7), so the first one we can compute is the one to use.
|
||||
*/
|
||||
export function selectDigestChallenge(
|
||||
challenges: AuthChallenge[],
|
||||
realm?: string,
|
||||
): DigestChallenge {
|
||||
const digestChallenges = challenges.filter((c) => c.scheme.toLowerCase() === "digest");
|
||||
|
||||
if (digestChallenges.length === 0) {
|
||||
const offered = challenges.map((c) => c.scheme).join(", ");
|
||||
throw new Error(
|
||||
offered === ""
|
||||
? "Server did not offer Digest authentication (no WWW-Authenticate header in the response)"
|
||||
: `Server did not offer Digest authentication. It offered: ${offered}`,
|
||||
);
|
||||
}
|
||||
|
||||
const inRealm =
|
||||
realm == null || realm === ""
|
||||
? digestChallenges
|
||||
: digestChallenges.filter((c) => c.params.realm === realm);
|
||||
|
||||
if (inRealm.length === 0) {
|
||||
const offered = digestChallenges.map((c) => JSON.stringify(c.params.realm ?? "")).join(", ");
|
||||
throw new Error(`Server did not offer a Digest realm named "${realm}". It offered: ${offered}`);
|
||||
}
|
||||
|
||||
const candidates = inRealm.map((c) => toDigestChallenge(c.params));
|
||||
const answerable = candidates.find((c) => challengeProblem(c) == null);
|
||||
if (answerable == null) throw challengeProblem(candidates[0]!);
|
||||
|
||||
return answerable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer `auth-int` only when the body is in hand, since its digest covers the
|
||||
* exact bytes sent. A body offered as `null` is either an empty one or one Yaak
|
||||
* didn't hand over (too large, or streamed from a file), and the two are
|
||||
* indistinguishable from here. When `auth-int` is all the server offers it is
|
||||
* still used, hashing the empty body: that is exactly right for the empty case
|
||||
* and no worse than refusing outright for the other.
|
||||
*/
|
||||
function selectQop(qop: string[], body: string | null): "auth" | "auth-int" {
|
||||
if (qop.includes("auth-int") && (body != null || !qop.includes("auth"))) return "auth-int";
|
||||
if (qop.includes("auth")) return "auth";
|
||||
throw unsupportedQopError(qop);
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
/** RFC 5987 `ext-value`, used for usernames that a quoted-string can't carry. */
|
||||
function encodeExtended(value: string): string {
|
||||
const encoded = encodeURIComponent(value).replace(
|
||||
/['()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
return `UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
export function buildDigestAuthorization(options: DigestAuthorizationOptions): string {
|
||||
const { method, uri, body, challenge, cnonce, nc } = options;
|
||||
|
||||
// RFC 7616 §4 hashes credentials in Normalization Form C, so a name typed as
|
||||
// a combining sequence digests the same as its precomposed spelling.
|
||||
const username = options.username.normalize("NFC");
|
||||
const password = options.password.normalize("NFC");
|
||||
|
||||
const algorithm = resolveAlgorithm(challenge.algorithm);
|
||||
if (algorithm == null) throw unsupportedAlgorithmError(challenge.algorithm);
|
||||
|
||||
const hash = (value: string) => createHash(algorithm.hash).update(value, "utf8").digest("hex");
|
||||
const qop = challenge.qop == null ? null : selectQop(challenge.qop, body);
|
||||
const ncHex = nc.toString(16).padStart(8, "0");
|
||||
|
||||
const secret = hash(`${username}:${challenge.realm}:${password}`);
|
||||
const ha1 = algorithm.sess ? hash(`${secret}:${challenge.nonce}:${cnonce}`) : secret;
|
||||
const ha2 =
|
||||
qop === "auth-int" ? hash(`${method}:${uri}:${hash(body ?? "")}`) : hash(`${method}:${uri}`);
|
||||
|
||||
// Without qop the server speaks RFC 2069, where the client contributes nothing
|
||||
// to the digest and so must not send cnonce, nc or qop back.
|
||||
const response =
|
||||
qop == null
|
||||
? hash(`${ha1}:${challenge.nonce}:${ha2}`)
|
||||
: hash(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`);
|
||||
|
||||
const params: string[] = [];
|
||||
params.push(
|
||||
/^[\x20-\x7E]*$/.test(username)
|
||||
? `username=${quote(username)}`
|
||||
: `username*=${encodeExtended(username)}`,
|
||||
);
|
||||
params.push(`realm=${quote(challenge.realm)}`);
|
||||
params.push(`uri=${quote(uri)}`);
|
||||
if (challenge.algorithm != null) params.push(`algorithm=${challenge.algorithm}`);
|
||||
params.push(`nonce=${quote(challenge.nonce)}`);
|
||||
if (qop != null) {
|
||||
params.push(`nc=${ncHex}`);
|
||||
params.push(`cnonce=${quote(cnonce)}`);
|
||||
params.push(`qop=${qop}`);
|
||||
}
|
||||
params.push(`response=${quote(response)}`);
|
||||
if (challenge.opaque != null) params.push(`opaque=${quote(challenge.opaque)}`);
|
||||
if (challenge.userhash) params.push("userhash=false");
|
||||
|
||||
return `Digest ${params.join(", ")}`;
|
||||
}
|
||||
|
||||
/** The origin-form request-target the digest is computed over. */
|
||||
export function requestTarget(url: string): string {
|
||||
const absolute = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(url) ? url : `http://${url}`;
|
||||
const parsed = new URL(absolute);
|
||||
return `${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PluginDefinition } from "@yaakapp/api";
|
||||
|
||||
import {
|
||||
buildDigestAuthorization,
|
||||
parseChallenges,
|
||||
requestTarget,
|
||||
selectDigestChallenge,
|
||||
} from "./digest";
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
authentication: {
|
||||
name: "digest",
|
||||
label: "Digest Auth",
|
||||
shortLabel: "Digest",
|
||||
args: [
|
||||
{
|
||||
type: "text",
|
||||
name: "username",
|
||||
label: "Username",
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "password",
|
||||
label: "Password",
|
||||
optional: true,
|
||||
password: true,
|
||||
},
|
||||
{
|
||||
type: "accordion",
|
||||
label: "Advanced",
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
name: "realm",
|
||||
label: "Realm",
|
||||
optional: true,
|
||||
description: "Only needed when the server offers more than one realm",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
async onApply(ctx, { values, method, url, body }) {
|
||||
const username = values.username ? String(values.username) : "";
|
||||
const password = values.password ? String(values.password) : "";
|
||||
const realm = values.realm ? String(values.realm) : undefined;
|
||||
|
||||
// Digest needs a server-issued nonce, so the challenge has to be provoked
|
||||
// before the real request can be signed. The probe carries nothing but the
|
||||
// method and URL: a cookie or an API key header would let it authorize the
|
||||
// very operation it is only meant to ask permission for, and there is no
|
||||
// telling a routing header from a credential by looking at it.
|
||||
const { httpResponse } = await ctx.httpRequest.send({ httpRequest: { method, url } });
|
||||
|
||||
const headerValues = httpResponse.headers
|
||||
.filter((h) => h.name.toLowerCase() === "www-authenticate")
|
||||
.map((h) => h.value);
|
||||
|
||||
const challenge = selectDigestChallenge(parseChallenges(headerValues), realm);
|
||||
const value = buildDigestAuthorization({
|
||||
username,
|
||||
password,
|
||||
method,
|
||||
uri: requestTarget(url),
|
||||
body,
|
||||
challenge,
|
||||
cnonce: randomBytes(16).toString("hex"),
|
||||
nc: 1,
|
||||
});
|
||||
|
||||
return { setHeaders: [{ name: "Authorization", value }] };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,390 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
buildDigestAuthorization,
|
||||
parseChallenges,
|
||||
requestTarget,
|
||||
selectDigestChallenge,
|
||||
toDigestChallenge,
|
||||
} from "../src/digest";
|
||||
|
||||
function paramOf(header: string, name: string): string | undefined {
|
||||
return parseChallenges([header])[0]?.params[name];
|
||||
}
|
||||
|
||||
describe("parseChallenges", () => {
|
||||
test("parses quoted and unquoted parameters", () => {
|
||||
expect(parseChallenges(['Digest realm="test", algorithm=MD5, stale=TRUE'])).toEqual([
|
||||
{ scheme: "Digest", params: { realm: "test", algorithm: "MD5", stale: "TRUE" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps commas and escapes inside quoted values", () => {
|
||||
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "qop")).toEqual(
|
||||
"auth,auth-int",
|
||||
);
|
||||
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "realm")).toEqual(
|
||||
'a "quoted" realm',
|
||||
);
|
||||
});
|
||||
|
||||
test("tolerates whitespace around the equals sign", () => {
|
||||
expect(paramOf('Digest realm = "test"', "realm")).toEqual("test");
|
||||
});
|
||||
|
||||
test("splits multiple challenges in a single header", () => {
|
||||
expect(parseChallenges(['Basic realm="a", Digest realm="b", nonce="n"'])).toEqual([
|
||||
{ scheme: "Basic", params: { realm: "a" } },
|
||||
{ scheme: "Digest", params: { realm: "b", nonce: "n" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("collects challenges across repeated headers", () => {
|
||||
expect(parseChallenges(['Digest realm="a"', "Negotiate"]).map((c) => c.scheme)).toEqual([
|
||||
"Digest",
|
||||
"Negotiate",
|
||||
]);
|
||||
});
|
||||
|
||||
test("captures token68 credentials rather than reading them as parameters", () => {
|
||||
expect(parseChallenges(["NTLM TlRMTVNTUAACAAAAAA=="])).toEqual([
|
||||
{ scheme: "NTLM", params: {}, token68: "TlRMTVNTUAACAAAAAA==" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("lower-cases parameter names", () => {
|
||||
expect(paramOf('Digest Realm="test", NONCE="n"', "realm")).toEqual("test");
|
||||
expect(paramOf('Digest Realm="test", NONCE="n"', "nonce")).toEqual("n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDigestChallenge", () => {
|
||||
test("splits qop and reads the boolean flags", () => {
|
||||
const challenge = toDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=" auth , AUTH-INT ", stale=true'])[0]!
|
||||
.params,
|
||||
);
|
||||
expect(challenge.qop).toEqual(["auth", "auth-int"]);
|
||||
expect(challenge.stale).toBe(true);
|
||||
expect(challenge.userhash).toBe(false);
|
||||
});
|
||||
|
||||
test("treats a missing qop as absent rather than empty", () => {
|
||||
expect(toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])[0]!.params).qop).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("reads userhash", () => {
|
||||
expect(
|
||||
toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n", userhash=TRUE'])[0]!.params)
|
||||
.userhash,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectDigestChallenge", () => {
|
||||
const md5 = 'Digest realm="a", nonce="n1", algorithm=MD5';
|
||||
const sha = 'Digest realm="b", nonce="n2", algorithm=SHA-256';
|
||||
|
||||
test("takes the first Digest challenge the server prefers", () => {
|
||||
expect(selectDigestChallenge(parseChallenges([sha, md5])).nonce).toEqual("n2");
|
||||
});
|
||||
|
||||
test("skips challenges whose algorithm is not supported", () => {
|
||||
const unsupported = 'Digest realm="c", nonce="n0", algorithm=SHA-512-256';
|
||||
expect(selectDigestChallenge(parseChallenges([unsupported, md5])).nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("skips challenges whose qop cannot be answered", () => {
|
||||
const unanswerable = 'Digest realm="c", nonce="n0", qop="auth-conf"';
|
||||
expect(selectDigestChallenge(parseChallenges([unanswerable, md5])).nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("skips challenges that carry no nonce", () => {
|
||||
expect(selectDigestChallenge(parseChallenges(['Digest realm="c"', md5])).nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("reports the first challenge's problem when none can be answered", () => {
|
||||
expect(() =>
|
||||
selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="c", nonce="n", qop="auth-conf"', 'Digest realm="d"']),
|
||||
),
|
||||
).toThrow("Unsupported Digest qop: auth-conf");
|
||||
});
|
||||
|
||||
test("matches the case-insensitive scheme name", () => {
|
||||
expect(selectDigestChallenge(parseChallenges(['digest realm="a", nonce="n1"'])).nonce).toEqual(
|
||||
"n1",
|
||||
);
|
||||
});
|
||||
|
||||
test("selects by realm when one is given", () => {
|
||||
expect(selectDigestChallenge(parseChallenges([sha, md5]), "a").nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("errors when the requested realm is not offered", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges([sha, md5]), "nope")).toThrow(
|
||||
'Server did not offer a Digest realm named "nope". It offered: "b", "a"',
|
||||
);
|
||||
});
|
||||
|
||||
test("errors when the server offers no Digest challenge", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges(['Basic realm="a"', "Negotiate"]))).toThrow(
|
||||
"Server did not offer Digest authentication. It offered: Basic, Negotiate",
|
||||
);
|
||||
});
|
||||
|
||||
test("errors when the response carries no challenge at all", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges([]))).toThrow(
|
||||
"no WWW-Authenticate header in the response",
|
||||
);
|
||||
});
|
||||
|
||||
test("errors when no offered algorithm is supported", () => {
|
||||
expect(() =>
|
||||
selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="c", nonce="n", algorithm=SHA-512-256']),
|
||||
),
|
||||
).toThrow("Unsupported Digest algorithm: SHA-512-256");
|
||||
});
|
||||
|
||||
test("errors when the challenge has no nonce", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges(['Digest realm="c"']))).toThrow(
|
||||
'Digest challenge is missing the required "nonce" parameter',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1
|
||||
describe("RFC 7616 §3.9.1 worked example", () => {
|
||||
const headers = [
|
||||
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=SHA-256, ' +
|
||||
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=MD5, ' +
|
||||
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
];
|
||||
const common = {
|
||||
username: "Mufasa",
|
||||
password: "Circle of Life",
|
||||
method: "GET",
|
||||
uri: "/dir/index.html",
|
||||
body: null,
|
||||
cnonce: "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
|
||||
nc: 1,
|
||||
};
|
||||
|
||||
test("SHA-256", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...common,
|
||||
challenge: selectDigestChallenge(parseChallenges(headers)),
|
||||
}),
|
||||
).toEqual(
|
||||
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
|
||||
'algorithm=SHA-256, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
|
||||
'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
);
|
||||
});
|
||||
|
||||
test("MD5", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...common,
|
||||
challenge: selectDigestChallenge(parseChallenges([headers[1]!])),
|
||||
}),
|
||||
).toEqual(
|
||||
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
|
||||
'algorithm=MD5, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
|
||||
'response="8ca523f5e9506fed4657c9700eebdbec", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2617#section-3.5
|
||||
describe("RFC 2617 §3.5 worked example", () => {
|
||||
test("MD5 with qop=auth", () => {
|
||||
const challenge = selectDigestChallenge(
|
||||
parseChallenges([
|
||||
'Digest realm="testrealm@host.com", qop="auth,auth-int", ' +
|
||||
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
username: "Mufasa",
|
||||
password: "Circle Of Life",
|
||||
method: "GET",
|
||||
uri: "/dir/index.html",
|
||||
body: null,
|
||||
challenge,
|
||||
cnonce: "0a4f113b",
|
||||
nc: 1,
|
||||
}),
|
||||
).toContain('response="6629fae49393a05397450978507c4ef1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDigestAuthorization", () => {
|
||||
const base = {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
method: "POST",
|
||||
uri: "/api",
|
||||
body: null as string | null,
|
||||
cnonce: "abc123",
|
||||
nc: 1,
|
||||
};
|
||||
|
||||
test("omits the client's contribution when the server offers no qop", () => {
|
||||
const header = buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
});
|
||||
expect(header).not.toContain("qop=");
|
||||
expect(header).not.toContain("cnonce=");
|
||||
expect(header).not.toContain("nc=");
|
||||
// MD5(HA1:nonce:HA2), per RFC 2069.
|
||||
expect(header).toContain('response="24644771b8983deed818b83aeb3ac381"');
|
||||
});
|
||||
|
||||
test("omits algorithm when the challenge did not name one", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
}),
|
||||
).not.toContain("algorithm=");
|
||||
});
|
||||
|
||||
test("uses auth-int over the body when the server offers it", () => {
|
||||
const header = buildDigestAuthorization({
|
||||
...base,
|
||||
body: '{"a":1}',
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
|
||||
),
|
||||
});
|
||||
expect(header).toContain("qop=auth-int");
|
||||
});
|
||||
|
||||
test("falls back to auth when no body was handed over", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
|
||||
),
|
||||
}),
|
||||
).toContain("qop=auth");
|
||||
});
|
||||
|
||||
test("uses auth-int over an empty body when it is the only qop offered", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth-int"']),
|
||||
),
|
||||
}),
|
||||
).toContain("qop=auth-int");
|
||||
});
|
||||
|
||||
test("rejects a qop it cannot compute", () => {
|
||||
expect(() =>
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth-conf"']),
|
||||
),
|
||||
}),
|
||||
).toThrow("Unsupported Digest qop: auth-conf");
|
||||
});
|
||||
|
||||
test("mixes the cnonce into HA1 for -sess algorithms", () => {
|
||||
const sess = buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5-sess']),
|
||||
),
|
||||
});
|
||||
const plain = buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5']),
|
||||
),
|
||||
});
|
||||
expect(sess).toContain("algorithm=MD5-sess");
|
||||
expect(sess).not.toEqual(plain);
|
||||
});
|
||||
|
||||
test("declines userhash when the server advertises it", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=auth, userhash=true']),
|
||||
),
|
||||
}),
|
||||
).toContain("userhash=false");
|
||||
});
|
||||
|
||||
test("escapes quotes in the credentials it echoes back", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
username: 'a"b',
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
}),
|
||||
).toContain('username="a\\"b"');
|
||||
});
|
||||
|
||||
test("normalizes credentials to NFC before hashing", () => {
|
||||
const challenge = selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"']));
|
||||
// "Jäsøn" spelled with a combining diaeresis rather than a precomposed "ä".
|
||||
const decomposed = buildDigestAuthorization({
|
||||
...base,
|
||||
username: "Ja\u0308s\u00f8n",
|
||||
password: "pa\u0308ss",
|
||||
challenge,
|
||||
});
|
||||
const precomposed = buildDigestAuthorization({
|
||||
...base,
|
||||
username: "J\u00e4s\u00f8n",
|
||||
password: "p\u00e4ss",
|
||||
challenge,
|
||||
});
|
||||
expect(decomposed).toEqual(precomposed);
|
||||
});
|
||||
|
||||
test("sends a non-ASCII username as an RFC 5987 extended value", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
username: "Jäsøn Doe",
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
}),
|
||||
).toContain("username*=UTF-8''J%C3%A4s%C3%B8n%20Doe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestTarget", () => {
|
||||
test("keeps the path and query", () => {
|
||||
expect(requestTarget("https://example.org/dir/index.html?a=b&c=d")).toEqual(
|
||||
"/dir/index.html?a=b&c=d",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses a bare slash when there is no path", () => {
|
||||
expect(requestTarget("https://example.org")).toEqual("/");
|
||||
});
|
||||
|
||||
test("handles a URL with no scheme", () => {
|
||||
expect(requestTarget("localhost:8080/thing")).toEqual("/thing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import { plugin } from "../src";
|
||||
|
||||
function apply(ctx: Context, values: Record<string, string>, over: Partial<ApplyArgs> = {}) {
|
||||
return plugin.authentication!.onApply(ctx, {
|
||||
values,
|
||||
headers: [],
|
||||
url: "https://example.org/dir/index.html?a=b",
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
type ApplyArgs = Parameters<NonNullable<typeof plugin.authentication>["onApply"]>[1];
|
||||
|
||||
function ctxRespondingWith(headers: Array<{ name: string; value: string }>): {
|
||||
ctx: Context;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const send = vi.fn().mockResolvedValue({ httpResponse: { headers } });
|
||||
return { ctx: { httpRequest: { send } } as unknown as Context, send };
|
||||
}
|
||||
|
||||
describe("auth-digest onApply", () => {
|
||||
test("probes with the same method and URL, without credentials or body", async () => {
|
||||
const { ctx, send } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
await apply(ctx, { username: "user", password: "pass" }, { method: "POST", body: "hello" });
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
httpRequest: { method: "POST", url: "https://example.org/dir/index.html?a=b" },
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps credential-bearing headers off the probe", async () => {
|
||||
const { ctx, send } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
await apply(
|
||||
ctx,
|
||||
{ username: "user", password: "pass" },
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: [
|
||||
{ name: "Cookie", value: "session=abc" },
|
||||
{ name: "X-Api-Key", value: "secret" },
|
||||
{ name: "Authorization", value: "Bearer stale" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
httpRequest: { method: "DELETE", url: "https://example.org/dir/index.html?a=b" },
|
||||
});
|
||||
});
|
||||
|
||||
test("signs the request-target rather than the whole URL", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
const result = await apply(ctx, { username: "user", password: "pass" });
|
||||
|
||||
expect(result.setHeaders?.[0]?.name).toEqual("Authorization");
|
||||
expect(result.setHeaders?.[0]?.value).toContain('uri="/dir/index.html?a=b"');
|
||||
});
|
||||
|
||||
test("uses a fresh cnonce on every apply", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
const first = await apply(ctx, { username: "user", password: "pass" });
|
||||
const second = await apply(ctx, { username: "user", password: "pass" });
|
||||
|
||||
expect(first.setHeaders?.[0]?.value).not.toEqual(second.setHeaders?.[0]?.value);
|
||||
});
|
||||
|
||||
test("treats missing credentials as empty strings", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "www-authenticate", value: 'Digest realm="r", nonce="n"' },
|
||||
]);
|
||||
|
||||
expect((await apply(ctx, {})).setHeaders?.[0]?.value).toContain('username=""');
|
||||
});
|
||||
|
||||
test("fails clearly when the server does not offer Digest", async () => {
|
||||
const { ctx } = ctxRespondingWith([{ name: "WWW-Authenticate", value: 'Basic realm="r"' }]);
|
||||
|
||||
await expect(apply(ctx, { username: "user", password: "pass" })).rejects.toThrow(
|
||||
"Server did not offer Digest authentication. It offered: Basic",
|
||||
);
|
||||
});
|
||||
|
||||
test("selects the realm the user configured", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="one", nonce="n1"' },
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="two", nonce="n2"' },
|
||||
]);
|
||||
|
||||
expect(
|
||||
(await apply(ctx, { username: "user", password: "pass", realm: "two" })).setHeaders?.[0]
|
||||
?.value,
|
||||
).toContain('nonce="n2"');
|
||||
});
|
||||
});
|
||||
|
||||
function nextComma(value: string, from: number): number {
|
||||
const index = value.indexOf(",", from);
|
||||
return index < 0 ? value.length : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `Digest name=value, name="value"` credentials by scanning, rather than
|
||||
* with a global regex: a repeated character class in front of the `=` backtracks
|
||||
* quadratically over a long run of the characters it accepts.
|
||||
*/
|
||||
function parseCredentials(header: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
let i = header.indexOf(" ") + 1;
|
||||
|
||||
while (i < header.length) {
|
||||
const equals = header.indexOf("=", i);
|
||||
if (equals < 0) break;
|
||||
|
||||
const name = header.slice(i, equals).trim().toLowerCase();
|
||||
i = equals + 1;
|
||||
|
||||
let value = "";
|
||||
if (header[i] === '"') {
|
||||
for (i++; i < header.length && header[i] !== '"'; i++) {
|
||||
if (header[i] === "\\") i++;
|
||||
value += header[i];
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
const end = nextComma(header, i);
|
||||
value = header.slice(i, end).trim();
|
||||
i = end;
|
||||
}
|
||||
|
||||
params[name] = value;
|
||||
i = nextComma(header, i) + 1;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimally correct Digest server, hashing inline rather than through the
|
||||
* plugin's own helpers so the round trip can't agree with itself on a mistake.
|
||||
*/
|
||||
function startDigestServer(config: {
|
||||
username: string;
|
||||
password: string;
|
||||
realm: string;
|
||||
nonce: string;
|
||||
algorithm?: string;
|
||||
qop?: string;
|
||||
/** Offer a second, unrelated realm ahead of the real one. */
|
||||
decoyRealm?: string;
|
||||
}): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const hashName = (config.algorithm ?? "MD5").toLowerCase().startsWith("sha-256")
|
||||
? "sha256"
|
||||
: "md5";
|
||||
const sess = (config.algorithm ?? "").toLowerCase().endsWith("-sess");
|
||||
const hash = (value: string) => createHash(hashName).update(value, "utf8").digest("hex");
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
const authorization = req.headers.authorization;
|
||||
if (authorization == null || !authorization.startsWith("Digest ")) {
|
||||
const challenge = [
|
||||
`Digest realm="${config.realm}"`,
|
||||
`nonce="${config.nonce}"`,
|
||||
`algorithm=${config.algorithm ?? "MD5"}`,
|
||||
config.qop == null ? null : `qop="${config.qop}"`,
|
||||
'opaque="0p4qu3"',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
res.setHeader(
|
||||
"WWW-Authenticate",
|
||||
config.decoyRealm == null
|
||||
? [challenge]
|
||||
: [
|
||||
`Digest realm="${config.decoyRealm}", nonce="wrong-nonce", algorithm=MD5`,
|
||||
challenge,
|
||||
],
|
||||
);
|
||||
res.writeHead(401).end("unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const params = parseCredentials(authorization);
|
||||
|
||||
const secret = hash(`${config.username}:${config.realm}:${config.password}`);
|
||||
const ha1 = sess ? hash(`${secret}:${params.nonce}:${params.cnonce}`) : secret;
|
||||
const ha2 =
|
||||
params.qop === "auth-int"
|
||||
? hash(`${req.method}:${params.uri}:${hash(Buffer.concat(chunks).toString("utf8"))}`)
|
||||
: hash(`${req.method}:${params.uri}`);
|
||||
const expected =
|
||||
params.qop == null
|
||||
? hash(`${ha1}:${params.nonce}:${ha2}`)
|
||||
: hash(`${ha1}:${params.nonce}:${params.nc}:${params.cnonce}:${params.qop}:${ha2}`);
|
||||
|
||||
const ok =
|
||||
params.username === config.username &&
|
||||
params.realm === config.realm &&
|
||||
params.nonce === config.nonce &&
|
||||
params.uri === req.url &&
|
||||
params.opaque === "0p4qu3" &&
|
||||
params.response === expected;
|
||||
|
||||
res.writeHead(ok ? 200 : 401).end(ok ? "welcome" : "denied");
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise<void>((done) => server.close(() => done())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends for real, so the plugin sees the headers a live server actually returns. */
|
||||
function realContext(): Context {
|
||||
return {
|
||||
httpRequest: {
|
||||
async send({ httpRequest }: { httpRequest: { method?: string; url?: string } }) {
|
||||
const res = await fetch(httpRequest.url!, { method: httpRequest.method });
|
||||
await res.text();
|
||||
return {
|
||||
httpResponse: {
|
||||
headers: [...res.headers].map(([name, value]) => ({ name, value })),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Context;
|
||||
}
|
||||
|
||||
describe("auth-digest against a live server", () => {
|
||||
let close: (() => Promise<void>) | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await close?.();
|
||||
close = null;
|
||||
});
|
||||
|
||||
for (const algorithm of ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"]) {
|
||||
test(`authenticates with algorithm=${algorithm}`, async () => {
|
||||
const server = await startDigestServer({
|
||||
username: "Mufasa",
|
||||
password: "Circle of Life",
|
||||
realm: "http-auth@example.org",
|
||||
nonce: "7ypf/xlj9XXwfDPEoM4URrv",
|
||||
algorithm,
|
||||
qop: "auth",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/dir/index.html?a=b`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "Mufasa", password: "Circle of Life" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: result.setHeaders![0]!.value },
|
||||
});
|
||||
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("authenticates a POST body with qop=auth-int", async () => {
|
||||
const body = '{"hello":"world"}';
|
||||
const server = await startDigestServer({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
realm: "api@example.org",
|
||||
nonce: "n0nc3",
|
||||
algorithm: "SHA-256",
|
||||
qop: "auth,auth-int",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/submit`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "user", password: "pass" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "POST",
|
||||
body,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
expect(result.setHeaders![0]!.value).toContain("qop=auth-int");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { Authorization: result.setHeaders![0]!.value },
|
||||
});
|
||||
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
|
||||
});
|
||||
|
||||
test("authenticates against an RFC 2069 server that offers no qop", async () => {
|
||||
const server = await startDigestServer({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
realm: "legacy@example.org",
|
||||
nonce: "old-nonce",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/legacy`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "user", password: "pass" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
|
||||
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
|
||||
});
|
||||
|
||||
test("picks the configured realm out of several the server offers", async () => {
|
||||
const server = await startDigestServer({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
realm: "second@example.org",
|
||||
nonce: "n0nc3",
|
||||
qop: "auth",
|
||||
decoyRealm: "first@example.org",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/multi`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "user", password: "pass", realm: "second@example.org" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user