Compare commits

...
Author SHA1 Message Date
Gregory Schier b2fa604022 Refresh PR #657 screenshot with operator-neutral disclosure 2026-09-14 21:26:26 -07:00
Gregory Schier 9d16dc87fe Refresh PR #657 screenshot with the external link icon 2026-09-14 21:20:19 -07:00
Gregory Schier ba6a8e39eb Refresh PR #657 screenshot with approved Yaak Web copy 2026-09-14 21:16:10 -07:00
Gregory Schier 8eb2368151 Refresh PR #657 screenshot with clearer secrets wording 2026-09-14 18:45:10 -07:00
Gregory Schier 34822442b2 Update PR #657 screenshot with storage details and tooltip 2026-09-14 18:41:04 -07:00
Gregory Schier 5149427c1b Update PR #657 screenshot with shorter notice 2026-09-14 18:37:35 -07:00
Gregory Schier bcb8b2e729 Add proxy notice screenshot for PR #657 2026-09-14 18:35:36 -07:00
Gregory Schier 7c660f4528 Correct the language table in the HTTP Snippet README (#656) 2026-09-14 13:41:33 -07:00
Gregory Schier 0337ef35c9 Describe the request template functions (#655) 2026-09-14 12:38:21 -07:00
Gregory SchierandClaude Fable 5 602bd1bb45 Move Install under the screenshot and License to the end of the README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 11:03:59 -07:00
Gregory SchierandClaude Fable 5 57ab349901 Add install and license sections to the README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 11:02:35 -07:00
Gregory SchierandClaude Opus 5 7a6d275fdb feat(auth-oauth2): custom parameters for authorization, token, and refresh requests (#629)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:15:25 -07:00
Gregory Schier 7d0926c961 fix(input): opt plain inputs out of macOS Writing Tools suggestions (#646) 2026-09-13 09:15:15 -07:00
Gregory SchierandClaude Opus 5 522b898620 feat(auth): add HTTP Digest authentication plugin (#628)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:15:06 -07:00
Gregory SchierandClaude Fable 5.1 cb7ed9264d feat(client): show an indicator on the Info tab when a request has a description (#645)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 09:12:42 -07:00
Gregory SchierandClaude Fable 5.1 90cb578e26 Route all database writes through one connection (#642)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 08:12:41 -07:00
Gregory SchierandClaude Fable 5.1 ff7eebf3cd fix(importer-curl): join line continuations that have trailing whitespace (#644)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:33:54 -07:00
Ngo Quoc Viet eff0c06cb0 fix(importer-postman): stop swapping the API key name and its value (#643) 2026-09-12 12:52:20 -07:00
dependabot[bot] c6e09a5af3 Bump vite-plus from 0.2.9 to 0.3.0 in the npm-development group (#632)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:56 -07:00
dependabot[bot] b05071ef4b Bump cpy-cli from 5.0.0 to 7.0.0 (#633)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:40 -07:00
dependabot[bot] a44f31eeb2 Bump cpx2 from 8.0.2 to 9.0.0 (#634)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:26 -07:00
dependabot[bot] 1103d05915 Bump zip from 4.0.0 to 7.0.0 (#637)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:15 -07:00
Gregory SchierandClaude Fable 5.1 2f5639a26f chore: cargo fmt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:35:33 -07:00
122 changed files with 5846 additions and 2865 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Generated
+51 -28
View File
@@ -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]]
+10
View File
@@ -25,6 +25,10 @@
![Yaak API Client](https://yaak.app/static/screenshot.png)
## 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"))}
+1 -1
View File
@@ -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"
}
}
+8
View File
@@ -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";
}
}
+1 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -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 }
+13 -10
View File
@@ -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);
+8 -8
View File
@@ -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(
+15 -16
View File
@@ -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");
+8 -8
View File
@@ -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(())
+6 -4
View File
@@ -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(())
+10 -8
View File
@@ -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(())
+3 -5
View File
@@ -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));
+1 -1
View File
@@ -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,
+5 -10
View File
@@ -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
};
+1 -2
View File
@@ -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)
+2 -5
View File
@@ -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
}
-1
View File
@@ -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 })
}
+287 -271
View File
@@ -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(
+19 -16
View File
@@ -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,
+1 -1
View File
@@ -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};
+472 -123
View File
@@ -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?)
}
+9 -6
View File
@@ -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>(
+135 -120
View File
@@ -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 {
+31 -12
View File
@@ -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);
+2 -1
View File
@@ -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
+4 -5
View File
@@ -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;
+5 -5
View File
@@ -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)
})
})
+1 -1
View File
@@ -72,7 +72,7 @@ pub trait Host: Clone {
self.query_manager().connect()
}
fn blobs(&self) -> BlobContext {
fn blobs(&self) -> BlobContext<'_> {
self.blob_manager().connect()
}
}
+25 -17
View File
@@ -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<()> {
+68 -59
View File
@@ -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()));
+9 -7
View File
@@ -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,
};
+32 -36
View File
@@ -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());
}
+165 -110
View File
@@ -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);
+45 -9
View File
@@ -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())
}
+50 -13
View File
@@ -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(())
})
}
+4 -4
View File
@@ -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,
+2 -2
View File
@@ -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>,
+10 -6
View File
@@ -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(
+84 -82
View File
@@ -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)
}
}
+96 -94
View File
@@ -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,
+47 -45
View File
@@ -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)
}
}
+111 -98
View File
@@ -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,
+3 -1
View File
@@ -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,
+1 -1
View File
@@ -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;
+98 -57
View File
@@ -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,
+3 -1
View File
@@ -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)
}
+46 -37
View File
@@ -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);
+99 -117
View File
@@ -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.
+44 -27
View File
@@ -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)
+3 -11
View File
@@ -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(),
+4 -10
View File
@@ -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?;
+3 -5
View File
@@ -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() {
+82 -51
View File
@@ -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)?;
Binary file not shown.
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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, &current);
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);
}
}
+76 -66
View File
@@ -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:?}"),
}
+33 -30
View File
@@ -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
View File
@@ -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))
+1 -1
View File
@@ -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>
+768 -627
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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"
},
+23 -5
View File
@@ -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"],
+1 -1
View File
@@ -33,6 +33,6 @@
"@types/node": "^24.0.13"
},
"devDependencies": {
"cpy-cli": "^5.0.0"
"cpy-cli": "^7.0.0"
}
}
+13
View File
@@ -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 };
+28 -21
View File
@@ -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
+56
View File
@@ -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.
+17
View File
@@ -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"
}
}
+294
View File
@@ -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}`;
}
+75
View File
@@ -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 }] };
},
},
};
+390
View File
@@ -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");
});
});
+375
View File
@@ -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