snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

28
crates/yaak/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "yaak"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true }
md5 = "0.8.0"
sha2 = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt"] }
yaak-http = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

21
crates/yaak/src/error.rs Normal file
View File

@@ -0,0 +1,21 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Send(#[from] crate::send::SendHttpRequestError),
#[error(transparent)]
Model(#[from] yaak_models::error::Error),
#[error(transparent)]
Plugin(#[from] yaak_plugins::error::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;

3019
crates/yaak/src/import.rs Normal file

File diff suppressed because it is too large Load Diff

8
crates/yaak/src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod error;
pub mod import;
pub mod plugin_events;
pub mod response_body;
pub mod send;
pub use error::Error;
pub type Result<T> = error::Result<T>;

View File

@@ -0,0 +1,815 @@
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;
use yaak_plugins::events::{
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest, GetHttpResponseBodyInfoResponse,
GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest,
ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse,
ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest,
PromptTextRequest, ReadHttpResponseBodyChunkRequest, ReadHttpResponseBodyChunkResponse,
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
UpsertModelResponse, WindowInfoRequest,
};
pub struct SharedPluginEventContext<'a> {
pub plugin_name: &'a str,
pub workspace_id: Option<&'a str>,
}
#[derive(Debug)]
pub enum GroupedPluginEvent<'a> {
Handled(Option<InternalEventPayload>),
ToHandle(HostRequest<'a>),
}
#[derive(Debug)]
pub enum GroupedPluginRequest<'a> {
Shared(SharedRequest<'a>),
Host(HostRequest<'a>),
Ignore,
}
#[derive(Debug)]
pub enum SharedRequest<'a> {
GetKeyValue(&'a GetKeyValueRequest),
SetKeyValue(&'a SetKeyValueRequest),
DeleteKeyValue(&'a DeleteKeyValueRequest),
GetHttpRequestById(&'a GetHttpRequestByIdRequest),
ListFolders(&'a ListFoldersRequest),
ListHttpRequests(&'a ListHttpRequestsRequest),
FindHttpResponses(&'a FindHttpResponsesRequest),
GetHttpResponseBodyInfo(&'a GetHttpResponseBodyInfoRequest),
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
UpsertModel(&'a UpsertModelRequest),
DeleteModel(&'a DeleteModelRequest),
}
#[derive(Debug)]
pub enum HostRequest<'a> {
ShowToast(&'a ShowToastRequest),
CopyText(&'a CopyTextRequest),
PromptText(&'a PromptTextRequest),
PromptForm(&'a PromptFormRequest),
RenderGrpcRequest(&'a RenderGrpcRequestRequest),
RenderHttpRequest(&'a RenderHttpRequestRequest),
TemplateRender(&'a TemplateRenderRequest),
SendHttpRequest(&'a SendHttpRequestRequest),
OpenWindow(&'a OpenWindowRequest),
CloseWindow(&'a CloseWindowRequest),
OpenExternalUrl(&'a OpenExternalUrlRequest),
ListOpenWorkspaces(&'a ListOpenWorkspacesRequest),
ListCookieNames(&'a ListCookieNamesRequest),
GetCookieValue(&'a GetCookieValueRequest),
WindowInfo(&'a WindowInfoRequest),
ErrorResponse(&'a ErrorResponse),
ReloadResponse(&'a ReloadResponse),
OtherRequest(&'a InternalEventPayload),
}
impl HostRequest<'_> {
pub fn type_name(&self) -> String {
match self {
HostRequest::ShowToast(_) => "show_toast_request".to_string(),
HostRequest::CopyText(_) => "copy_text_request".to_string(),
HostRequest::PromptText(_) => "prompt_text_request".to_string(),
HostRequest::PromptForm(_) => "prompt_form_request".to_string(),
HostRequest::RenderGrpcRequest(_) => "render_grpc_request_request".to_string(),
HostRequest::RenderHttpRequest(_) => "render_http_request_request".to_string(),
HostRequest::TemplateRender(_) => "template_render_request".to_string(),
HostRequest::SendHttpRequest(_) => "send_http_request_request".to_string(),
HostRequest::OpenWindow(_) => "open_window_request".to_string(),
HostRequest::CloseWindow(_) => "close_window_request".to_string(),
HostRequest::OpenExternalUrl(_) => "open_external_url_request".to_string(),
HostRequest::ListOpenWorkspaces(_) => "list_open_workspaces_request".to_string(),
HostRequest::ListCookieNames(_) => "list_cookie_names_request".to_string(),
HostRequest::GetCookieValue(_) => "get_cookie_value_request".to_string(),
HostRequest::WindowInfo(_) => "window_info_request".to_string(),
HostRequest::ErrorResponse(_) => "error_response".to_string(),
HostRequest::ReloadResponse(_) => "reload_response".to_string(),
HostRequest::OtherRequest(payload) => payload.type_name(),
}
}
}
impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
fn from(payload: &'a InternalEventPayload) -> Self {
match payload {
InternalEventPayload::GetKeyValueRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::GetKeyValue(req))
}
InternalEventPayload::SetKeyValueRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::SetKeyValue(req))
}
InternalEventPayload::DeleteKeyValueRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::DeleteKeyValue(req))
}
InternalEventPayload::GetHttpRequestByIdRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::GetHttpRequestById(req))
}
InternalEventPayload::ErrorResponse(resp) => {
GroupedPluginRequest::Host(HostRequest::ErrorResponse(resp))
}
InternalEventPayload::ReloadResponse(req) => {
GroupedPluginRequest::Host(HostRequest::ReloadResponse(req))
}
InternalEventPayload::ListOpenWorkspacesRequest(req) => {
GroupedPluginRequest::Host(HostRequest::ListOpenWorkspaces(req))
}
InternalEventPayload::ListFoldersRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::ListFolders(req))
}
InternalEventPayload::ListHttpRequestsRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::ListHttpRequests(req))
}
InternalEventPayload::ShowToastRequest(req) => {
GroupedPluginRequest::Host(HostRequest::ShowToast(req))
}
InternalEventPayload::CopyTextRequest(req) => {
GroupedPluginRequest::Host(HostRequest::CopyText(req))
}
InternalEventPayload::PromptTextRequest(req) => {
GroupedPluginRequest::Host(HostRequest::PromptText(req))
}
InternalEventPayload::PromptFormRequest(req) => {
GroupedPluginRequest::Host(HostRequest::PromptForm(req))
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))
}
InternalEventPayload::GetHttpResponseBodyInfoRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::GetHttpResponseBodyInfo(req))
}
InternalEventPayload::ReadHttpResponseBodyChunkRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::ReadHttpResponseBodyChunk(req))
}
InternalEventPayload::UpsertModelRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
}
InternalEventPayload::DeleteModelRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))
}
InternalEventPayload::RenderGrpcRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
}
InternalEventPayload::RenderHttpRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::RenderHttpRequest(req))
}
InternalEventPayload::TemplateRenderRequest(req) => {
GroupedPluginRequest::Host(HostRequest::TemplateRender(req))
}
InternalEventPayload::SendHttpRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::SendHttpRequest(req))
}
InternalEventPayload::OpenWindowRequest(req) => {
GroupedPluginRequest::Host(HostRequest::OpenWindow(req))
}
InternalEventPayload::CloseWindowRequest(req) => {
GroupedPluginRequest::Host(HostRequest::CloseWindow(req))
}
InternalEventPayload::OpenExternalUrlRequest(req) => {
GroupedPluginRequest::Host(HostRequest::OpenExternalUrl(req))
}
InternalEventPayload::ListCookieNamesRequest(req) => {
GroupedPluginRequest::Host(HostRequest::ListCookieNames(req))
}
InternalEventPayload::GetCookieValueRequest(req) => {
GroupedPluginRequest::Host(HostRequest::GetCookieValue(req))
}
InternalEventPayload::WindowInfoRequest(req) => {
GroupedPluginRequest::Host(HostRequest::WindowInfo(req))
}
payload if payload.type_name().ends_with("_request") => {
GroupedPluginRequest::Host(HostRequest::OtherRequest(payload))
}
_ => GroupedPluginRequest::Ignore,
}
}
}
pub fn handle_shared_plugin_event<'a>(
query_manager: &QueryManager,
body_store: &dyn ResponseBodyStore,
payload: &'a InternalEventPayload,
context: SharedPluginEventContext<'_>,
) -> GroupedPluginEvent<'a> {
match GroupedPluginRequest::from(payload) {
GroupedPluginRequest::Shared(req) => GroupedPluginEvent::Handled(Some(build_shared_reply(
query_manager,
body_store,
req,
context,
))),
GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),
GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),
}
}
fn build_shared_reply(
query_manager: &QueryManager,
body_store: &dyn ResponseBodyStore,
request: SharedRequest<'_>,
context: SharedPluginEventContext<'_>,
) -> InternalEventPayload {
match request {
SharedRequest::GetKeyValue(req) => {
let value = query_manager
.connect()
.get_plugin_key_value(context.plugin_name, &req.key)
.map(|v| v.value);
InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })
}
SharedRequest::SetKeyValue(req) => {
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
.with_tx(|tx| tx.delete_plugin_key_value(context.plugin_name, &req.key))
{
Ok(deleted) => {
InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted })
}
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete plugin key '{}' : {err}", req.key),
}),
}
}
SharedRequest::GetHttpRequestById(req) => {
let http_request = query_manager.connect().get_http_request(&req.id).ok();
InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
http_request,
})
}
SharedRequest::ListFolders(_) => {
let Some(workspace_id) = context.workspace_id else {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required for list_folders_request".to_string(),
});
};
let folders = match query_manager.connect().list_folders(workspace_id) {
Ok(folders) => folders,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list folders: {err}"),
});
}
};
InternalEventPayload::ListFoldersResponse(ListFoldersResponse { folders })
}
SharedRequest::ListHttpRequests(req) => {
let http_requests = if let Some(folder_id) = req.folder_id.as_deref() {
match query_manager.connect().list_http_requests_for_folder_recursive(folder_id) {
Ok(http_requests) => http_requests,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list HTTP requests for folder: {err}"),
});
}
}
} else {
let Some(workspace_id) = context.workspace_id else {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error:
"workspace_id is required for list_http_requests_request without folder_id"
.to_string(),
});
};
match query_manager.connect().list_http_requests(workspace_id) {
Ok(http_requests) => http_requests,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list HTTP requests: {err}"),
});
}
}
};
InternalEventPayload::ListHttpRequestsResponse(ListHttpRequestsResponse {
http_requests,
})
}
SharedRequest::FindHttpResponses(req) => {
let http_responses = query_manager
.connect()
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
.unwrap_or_default();
InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
http_responses,
})
}
SharedRequest::GetHttpResponseBodyInfo(req) => match body_store.info(&req.response_id) {
Ok(info) => InternalEventPayload::GetHttpResponseBodyInfoResponse(
GetHttpResponseBodyInfoResponse {
content_length: info.content_length,
content_type: info.content_type,
complete: info.complete,
},
),
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to read body of response {}: {err}", req.response_id),
}),
},
SharedRequest::ReadHttpResponseBodyChunk(req) => {
match body_store.read_chunk(&req.response_id, req.offset, req.length) {
Ok(bytes) => InternalEventPayload::ReadHttpResponseBodyChunkResponse(
ReadHttpResponseBodyChunkResponse {
length: bytes.len() as u64,
data: BASE64_STANDARD.encode(bytes),
},
),
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to read body of response {}: {err}", req.response_id),
}),
}
}
SharedRequest::UpsertModel(req) => {
use AnyModel::*;
let model = match &req.model {
HttpRequest(m) => {
match query_manager
.with_tx(|tx| tx.upsert_http_request(m, &UpdateSource::Plugin))
{
Ok(model) => HttpRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert HTTP request: {err}"),
});
}
}
}
GrpcRequest(m) => {
match query_manager
.with_tx(|tx| tx.upsert_grpc_request(m, &UpdateSource::Plugin))
{
Ok(model) => GrpcRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert gRPC request: {err}"),
});
}
}
}
WebsocketRequest(m) => {
match query_manager
.with_tx(|tx| tx.upsert_websocket_request(m, &UpdateSource::Plugin))
{
Ok(model) => WebsocketRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert WebSocket request: {err}"),
});
}
}
}
Folder(m) => {
match query_manager.with_tx(|tx| tx.upsert_folder(m, &UpdateSource::Plugin)) {
Ok(model) => Folder(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert folder: {err}"),
});
}
}
}
Environment(m) => {
match query_manager
.with_tx(|tx| tx.upsert_environment(m, &UpdateSource::Plugin))
{
Ok(model) => Environment(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert environment: {err}"),
});
}
}
}
Workspace(m) => {
match query_manager.with_tx(|tx| tx.upsert_workspace(m, &UpdateSource::Plugin))
{
Ok(model) => Workspace(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert workspace: {err}"),
});
}
}
}
_ => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Upsert not supported for this model type".to_string(),
});
}
};
InternalEventPayload::UpsertModelResponse(UpsertModelResponse { model })
}
SharedRequest::DeleteModel(req) => {
let model = match req.model.as_str() {
"http_request" => {
match query_manager
.with_tx(|tx| tx.delete_http_request_by_id(&req.id, &UpdateSource::Plugin))
{
Ok(model) => AnyModel::HttpRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete HTTP request: {err}"),
});
}
}
}
"grpc_request" => {
match query_manager
.with_tx(|tx| tx.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin))
{
Ok(model) => AnyModel::GrpcRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete gRPC request: {err}"),
});
}
}
}
"websocket_request" => {
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 {
error: format!("Failed to delete WebSocket request: {err}"),
});
}
}
}
"folder" => match query_manager
.with_tx(|tx| tx.delete_folder_by_id(&req.id, &UpdateSource::Plugin))
{
Ok(model) => AnyModel::Folder(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete folder: {err}"),
});
}
},
"environment" => {
match query_manager
.with_tx(|tx| tx.delete_environment_by_id(&req.id, &UpdateSource::Plugin))
{
Ok(model) => AnyModel::Environment(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete environment: {err}"),
});
}
}
}
_ => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Delete not supported for this model type".to_string(),
});
}
};
InternalEventPayload::DeleteModelResponse(DeleteModelResponse { model })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
use std::cell::RefCell;
use tempfile::TempDir;
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
use yaak_models::util::UpdateSource;
/// The real dispatch, with the store the desktop and CLI hand it.
fn dispatch<'a>(
query_manager: &QueryManager,
payload: &'a InternalEventPayload,
context: SharedPluginEventContext<'_>,
) -> GroupedPluginEvent<'a> {
handle_shared_plugin_event(
query_manager,
&FileResponseBodyStore::new(query_manager),
payload,
context,
)
}
fn seed_query_manager() -> (QueryManager, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("db.sqlite");
let blob_path = temp_dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB");
query_manager
.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)
}
#[test]
fn list_requests_requires_workspace_when_folder_missing() {
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
assert!(matches!(
result,
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(_)))
));
}
#[test]
fn list_requests_by_workspace_and_folder() {
let (query_manager, _temp_dir) = seed_query_manager();
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let by_workspace = dispatch(
&query_manager,
&by_workspace_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match by_workspace {
GroupedPluginEvent::Handled(Some(InternalEventPayload::ListHttpRequestsResponse(
resp,
))) => {
assert_eq!(resp.http_requests.len(), 1);
}
other => panic!("unexpected workspace response: {other:?}"),
}
let by_folder_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest {
folder_id: Some("fl_test".to_string()),
},
);
let by_folder = dispatch(
&query_manager,
&by_folder_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match by_folder {
GroupedPluginEvent::Handled(Some(InternalEventPayload::ListHttpRequestsResponse(
resp,
))) => {
assert_eq!(resp.http_requests.len(), 1);
}
other => panic!("unexpected folder response: {other:?}"),
}
}
#[test]
fn find_http_responses_is_shared_handled() {
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::FindHttpResponsesRequest(FindHttpResponsesRequest {
request_id: "rq_test".to_string(),
limit: Some(1),
});
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::FindHttpResponsesResponse(
resp,
))) => {
assert!(resp.http_responses.is_empty());
}
other => panic!("unexpected find responses result: {other:?}"),
}
}
/// A store that answers from memory, standing in for whatever holds the
/// bytes — the point being that the dispatch below never learns which.
struct FakeBodyStore {
body: Vec<u8>,
reads: RefCell<Vec<(u64, u64)>>,
}
impl ResponseBodyStore for FakeBodyStore {
fn info(&self, _response_id: &str) -> crate::error::Result<ResponseBodyInfo> {
Ok(ResponseBodyInfo {
content_length: self.body.len() as u64,
content_type: Some("text/plain; charset=utf-8".to_string()),
complete: true,
})
}
fn read_chunk(
&self,
_response_id: &str,
offset: u64,
length: u64,
) -> crate::error::Result<Vec<u8>> {
self.reads.borrow_mut().push((offset, length));
let start = (offset as usize).min(self.body.len());
let end = (start + length as usize).min(self.body.len());
Ok(self.body[start..end].to_vec())
}
}
#[test]
fn response_body_is_read_by_id_through_the_store() {
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 = handle_shared_plugin_event(
&query_manager,
&store,
&info_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match info {
GroupedPluginEvent::Handled(Some(
InternalEventPayload::GetHttpResponseBodyInfoResponse(resp),
)) => {
assert_eq!(resp.content_length, 5);
assert_eq!(resp.content_type.as_deref(), Some("text/plain; charset=utf-8"));
}
other => panic!("unexpected body info result: {other:?}"),
}
let chunk_payload = InternalEventPayload::ReadHttpResponseBodyChunkRequest(
ReadHttpResponseBodyChunkRequest {
response_id: "rs_test".to_string(),
offset: 1,
length: 3,
},
);
let chunk = handle_shared_plugin_event(
&query_manager,
&store,
&chunk_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match chunk {
GroupedPluginEvent::Handled(Some(
InternalEventPayload::ReadHttpResponseBodyChunkResponse(resp),
)) => {
assert_eq!(resp.length, 3);
assert_eq!(BASE64_STANDARD.decode(resp.data).unwrap(), b"ell");
}
other => panic!("unexpected body chunk result: {other:?}"),
}
assert_eq!(*store.reads.borrow(), vec![(1, 3)]);
}
#[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 result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
assert!(
resp.error.contains("rs_never_persisted"),
"unhelpful error: {}",
resp.error
)
}
other => panic!("unexpected missing-response result: {other:?}"),
}
}
#[test]
fn upsert_and_delete_model_are_shared_handled() {
let (query_manager, _temp_dir) = seed_query_manager();
let existing = query_manager
.connect()
.get_http_request("rq_test")
.expect("Failed to load seeded request");
let upsert_payload = InternalEventPayload::UpsertModelRequest(UpsertModelRequest {
model: AnyModel::HttpRequest(HttpRequest {
name: "Request Updated".to_string(),
..existing
}),
});
let upsert_result = dispatch(
&query_manager,
&upsert_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match upsert_result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::UpsertModelResponse(resp))) => {
match resp.model {
AnyModel::HttpRequest(r) => assert_eq!(r.name, "Request Updated"),
other => panic!("unexpected upsert model type: {other:?}"),
}
}
other => panic!("unexpected upsert result: {other:?}"),
}
let delete_payload = InternalEventPayload::DeleteModelRequest(DeleteModelRequest {
model: "http_request".to_string(),
id: "rq_test".to_string(),
});
let delete_result = dispatch(
&query_manager,
&delete_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match delete_result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::DeleteModelResponse(resp))) => {
match resp.model {
AnyModel::HttpRequest(r) => assert_eq!(r.id, "rq_test"),
other => panic!("unexpected delete model type: {other:?}"),
}
}
other => panic!("unexpected delete result: {other:?}"),
}
}
#[test]
fn host_request_classification_works() {
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
label: "main".to_string(),
});
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match result {
GroupedPluginEvent::ToHandle(HostRequest::WindowInfo(req)) => {
assert_eq!(req.label, "main")
}
other => panic!("unexpected host classification: {other:?}"),
}
}
}

View File

@@ -0,0 +1,235 @@
//! Reading response bodies back out, by response id.
//!
//! Plugins only ever name a response. Where its bytes actually live — files the
//! engine wrote under `<data dir>/responses/<id>` today, blob rows later — is
//! behind [`ResponseBodyStore`], so moving the bytes is a change to this file
//! and nothing a plugin can see.
//!
//! Only saved responses are reachable by id. A send that saved nothing hands
//! its body back with the reply instead, which is the only copy of it there is.
use crate::error::Result;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use yaak_models::models::HttpResponseState;
use yaak_models::query_manager::QueryManager;
/// The most bytes one read will hand back, however much was asked for.
///
/// A chunk is buffered whole and, on the desktop transport, base64'd into a
/// single WebSocket frame, so an unbounded request is a way to make the host
/// allocate on a plugin's say-so.
pub const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
/// What a stored body is, without reading any of it.
#[derive(Debug, Clone, Default)]
pub struct ResponseBodyInfo {
/// Bytes actually stored, which is not necessarily what `Content-Length`
/// claimed. Zero when the response has no body.
pub content_length: u64,
/// The response's `Content-Type` header, verbatim.
pub content_type: Option<String>,
/// Whether the response has finished arriving, so `content_length` is
/// final. A body still being written grows past it.
pub complete: bool,
}
/// Somewhere response bodies can be read from, a window at a time.
///
/// Reads are repeatable — the bytes are durable, so nothing is consumed by
/// looking at it.
pub trait ResponseBodyStore {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo>;
/// Bytes `[offset, offset + length)`, clamped to what is there. A short
/// read means the body ended.
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>>;
}
/// The desktop and CLI store: the database says where the file is, and the
/// filesystem holds it.
pub struct FileResponseBodyStore<'a> {
query_manager: &'a QueryManager,
}
impl<'a> FileResponseBodyStore<'a> {
pub fn new(query_manager: &'a QueryManager) -> Self {
Self { query_manager }
}
/// The file backing a response, or `None` when it stored no body.
///
/// Only responses the store knows about are reachable here. A send with no
/// request behind it never reaches the store at all, and its bytes come
/// back from the send instead — see `SendHttpRequestResponse::body`.
fn body_path(&self, response_id: &str) -> Result<Option<String>> {
Ok(self.query_manager.connect().get_http_response(response_id)?.body_path)
}
}
impl ResponseBodyStore for FileResponseBodyStore<'_> {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
let response = self.query_manager.connect().get_http_response(response_id)?;
let content_type = response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone());
let content_length = match response.body_path {
Some(path) => std::fs::metadata(path)?.len(),
None => 0,
};
Ok(ResponseBodyInfo {
content_length,
content_type,
// Closed is the one terminal state: success, error, and cancel all end there.
complete: matches!(response.state, HttpResponseState::Closed),
})
}
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>> {
let Some(path) = self.body_path(response_id)? else {
return Ok(Vec::new());
};
let length = length.min(MAX_CHUNK_BYTES);
if length == 0 {
return Ok(Vec::new());
}
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut buf = Vec::new();
file.take(length).read_to_end(&mut buf)?;
Ok(buf)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
use yaak_models::util::UpdateSource;
fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) {
let temp_dir = TempDir::new().unwrap();
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
&temp_dir.path().join("db.sqlite"),
&temp_dir.path().join("blobs.sqlite"),
)
.unwrap();
query_manager
.with_tx(|tx| {
tx.upsert_workspace(
&Workspace { id: "wk_test".to_string(), ..Default::default() },
&UpdateSource::Sync,
)
})
.unwrap();
query_manager
.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| {
let path = temp_dir.path().join("body");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(bytes).unwrap();
path.to_string_lossy().to_string()
});
let response = query_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();
(query_manager, temp_dir, id)
}
#[test]
fn info_reports_stored_size_and_content_type() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let info = FileResponseBodyStore::new(&qm).info(&id).unwrap();
assert_eq!(info.content_length, 11);
assert_eq!(info.content_type.as_deref(), Some("application/json; charset=utf-8"));
}
#[test]
fn chunks_cover_the_body_and_stop_short_at_the_end() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let store = FileResponseBodyStore::new(&qm);
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
assert_eq!(store.read_chunk(&id, 6, 100).unwrap(), b"world");
assert!(store.read_chunk(&id, 11, 100).unwrap().is_empty());
// Reading the same window twice gives the same bytes; nothing is consumed.
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
}
#[test]
fn a_response_with_no_body_is_empty_not_an_error() {
let (qm, _tmp, id) = seed(None);
let store = FileResponseBodyStore::new(&qm);
assert_eq!(store.info(&id).unwrap().content_length, 0);
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
}
#[test]
fn complete_tracks_whether_the_response_has_closed() {
let (qm, _tmp, id) = seed(Some(b"partial"));
// Seeded responses default to Initialized: still arriving.
assert!(!FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
let mut response = qm.connect().get_http_response(&id).unwrap();
response.state = HttpResponseState::Closed;
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
assert!(FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
}
#[test]
fn an_unknown_response_fails() {
let (qm, _tmp, _id) = seed(Some(b"hi"));
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
}
#[test]
fn an_unsaved_response_is_not_reachable_by_id() {
// Its bytes rode back with the send; there is nothing here to find, and
// guessing at a file named for the id is exactly what this must not do.
let (qm, tmp, _id) = seed(Some(b"hi"));
std::fs::write(tmp.path().join("rs_ephemeral1"), b"access_token=abc").unwrap();
assert!(FileResponseBodyStore::new(&qm).info("rs_ephemeral1").is_err());
}
}

1539
crates/yaak/src/send.rs Normal file

File diff suppressed because it is too large Load Diff