mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-26 13:24:48 +02:00
Add a plugin API for reading HTTP response bodies
Plugins read bodies by response id through ctx.httpResponse.body() instead of opening HttpResponse.bodyPath themselves. The accessors are named after fetch's, minus the single-use semantics, since the bytes are durable and re-reading should work. Underneath is a chunked pull over the existing plugin protocol, so the host can move bodies off the filesystem without plugins noticing. text() now decodes with the response's charset rather than assuming UTF-8, and the buffering accessors refuse past 32 MiB and point at chunks().
This commit is contained in:
Generated
+1
@@ -11196,6 +11196,7 @@ name = "yaak"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
"md5 0.8.0",
|
"md5 0.8.0",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use yaak::plugin_events::{
|
|||||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||||
};
|
};
|
||||||
use yaak::render::{render_grpc_request, render_http_request};
|
use yaak::render::{render_grpc_request, render_http_request};
|
||||||
|
use yaak::response_body::FileResponseBodyStore;
|
||||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||||
@@ -131,6 +132,7 @@ async fn build_plugin_reply(
|
|||||||
|
|
||||||
match handle_shared_plugin_event(
|
match handle_shared_plugin_event(
|
||||||
&host_context.query_manager,
|
&host_context.query_manager,
|
||||||
|
&FileResponseBodyStore::new(&host_context.query_manager),
|
||||||
&event.payload,
|
&event.payload,
|
||||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
use yaak::plugin_events::{
|
use yaak::plugin_events::{
|
||||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||||
};
|
};
|
||||||
|
use yaak::response_body::FileResponseBodyStore;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||||
use yaak_models::models::{HttpResponse, Plugin};
|
use yaak_models::models::{HttpResponse, Plugin};
|
||||||
@@ -54,6 +55,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
|
|
||||||
match handle_shared_plugin_event(
|
match handle_shared_plugin_event(
|
||||||
app_handle.db_manager().inner(),
|
app_handle.db_manager().inner(),
|
||||||
|
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
||||||
&event.payload,
|
&event.payload,
|
||||||
SharedPluginEventContext {
|
SharedPluginEventContext {
|
||||||
plugin_name: &plugin_name,
|
plugin_name: &plugin_name,
|
||||||
|
|||||||
+42
-1
File diff suppressed because one or more lines are too long
@@ -171,6 +171,12 @@ pub enum InternalEventPayload {
|
|||||||
|
|
||||||
FindHttpResponsesRequest(FindHttpResponsesRequest),
|
FindHttpResponsesRequest(FindHttpResponsesRequest),
|
||||||
FindHttpResponsesResponse(FindHttpResponsesResponse),
|
FindHttpResponsesResponse(FindHttpResponsesResponse),
|
||||||
|
|
||||||
|
GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest),
|
||||||
|
GetHttpResponseBodyInfoResponse(GetHttpResponseBodyInfoResponse),
|
||||||
|
ReadHttpResponseBodyChunkRequest(ReadHttpResponseBodyChunkRequest),
|
||||||
|
ReadHttpResponseBodyChunkResponse(ReadHttpResponseBodyChunkResponse),
|
||||||
|
|
||||||
ListHttpRequestsRequest(ListHttpRequestsRequest),
|
ListHttpRequestsRequest(ListHttpRequestsRequest),
|
||||||
ListHttpRequestsResponse(ListHttpRequestsResponse),
|
ListHttpRequestsResponse(ListHttpRequestsResponse),
|
||||||
ListFoldersRequest(ListFoldersRequest),
|
ListFoldersRequest(ListFoldersRequest),
|
||||||
@@ -1413,6 +1419,62 @@ pub struct FindHttpResponsesResponse {
|
|||||||
pub http_responses: Vec<HttpResponse>,
|
pub http_responses: Vec<HttpResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask what a response's body is, before deciding whether to pull it.
|
||||||
|
///
|
||||||
|
/// Bodies are addressed by response id and never by path, so where the host
|
||||||
|
/// keeps the bytes is its own business.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
pub struct GetHttpResponseBodyInfoRequest {
|
||||||
|
pub response_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
pub struct GetHttpResponseBodyInfoResponse {
|
||||||
|
/// How many bytes are actually stored, which is not necessarily what the
|
||||||
|
/// `Content-Length` header claimed. Zero when the response has no body.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub content_length: u64,
|
||||||
|
|
||||||
|
/// The response's `Content-Type` header, verbatim, so the reader can pick a
|
||||||
|
/// charset.
|
||||||
|
#[ts(optional = nullable)]
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull one window of a response body.
|
||||||
|
///
|
||||||
|
/// Reads are idempotent: the bytes live in durable storage, so the same window
|
||||||
|
/// can be asked for as many times as the plugin likes.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
pub struct ReadHttpResponseBodyChunkRequest {
|
||||||
|
pub response_id: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub offset: u64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
pub struct ReadHttpResponseBodyChunkResponse {
|
||||||
|
/// Base64, because the desktop transport is a WebSocket that only sends
|
||||||
|
/// text frames today. A host that can carry binary sends the bytes as they
|
||||||
|
/// are and fills this in from them.
|
||||||
|
pub data: String,
|
||||||
|
|
||||||
|
/// Bytes decoded from `data`. Short of the requested length means the body
|
||||||
|
/// ended here.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_events.ts")]
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
md5 = "0.8.0"
|
md5 = "0.8.0"
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod export;
|
|||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod plugin_events;
|
pub mod plugin_events;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
|
pub mod response_body;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
use crate::response_body::ResponseBodyStore;
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::prelude::BASE64_STANDARD;
|
||||||
use yaak_models::models::AnyModel;
|
use yaak_models::models::AnyModel;
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
@@ -5,12 +8,14 @@ use yaak_plugins::events::{
|
|||||||
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
||||||
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
|
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
|
||||||
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
|
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
|
||||||
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
|
GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest, GetHttpResponseBodyInfoResponse,
|
||||||
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
|
GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest,
|
||||||
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
|
ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse,
|
||||||
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
|
ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest,
|
||||||
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
|
PromptTextRequest, ReadHttpResponseBodyChunkRequest, ReadHttpResponseBodyChunkResponse,
|
||||||
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
|
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
|
||||||
|
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
|
||||||
|
UpsertModelResponse, WindowInfoRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SharedPluginEventContext<'a> {
|
pub struct SharedPluginEventContext<'a> {
|
||||||
@@ -40,6 +45,8 @@ pub enum SharedRequest<'a> {
|
|||||||
ListFolders(&'a ListFoldersRequest),
|
ListFolders(&'a ListFoldersRequest),
|
||||||
ListHttpRequests(&'a ListHttpRequestsRequest),
|
ListHttpRequests(&'a ListHttpRequestsRequest),
|
||||||
FindHttpResponses(&'a FindHttpResponsesRequest),
|
FindHttpResponses(&'a FindHttpResponsesRequest),
|
||||||
|
GetHttpResponseBodyInfo(&'a GetHttpResponseBodyInfoRequest),
|
||||||
|
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
|
||||||
UpsertModel(&'a UpsertModelRequest),
|
UpsertModel(&'a UpsertModelRequest),
|
||||||
DeleteModel(&'a DeleteModelRequest),
|
DeleteModel(&'a DeleteModelRequest),
|
||||||
}
|
}
|
||||||
@@ -136,6 +143,12 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
|||||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||||
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(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) => {
|
InternalEventPayload::UpsertModelRequest(req) => {
|
||||||
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
|
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
|
||||||
}
|
}
|
||||||
@@ -182,13 +195,17 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
|||||||
|
|
||||||
pub fn handle_shared_plugin_event<'a>(
|
pub fn handle_shared_plugin_event<'a>(
|
||||||
query_manager: &QueryManager,
|
query_manager: &QueryManager,
|
||||||
|
body_store: &dyn ResponseBodyStore,
|
||||||
payload: &'a InternalEventPayload,
|
payload: &'a InternalEventPayload,
|
||||||
context: SharedPluginEventContext<'_>,
|
context: SharedPluginEventContext<'_>,
|
||||||
) -> GroupedPluginEvent<'a> {
|
) -> GroupedPluginEvent<'a> {
|
||||||
match GroupedPluginRequest::from(payload) {
|
match GroupedPluginRequest::from(payload) {
|
||||||
GroupedPluginRequest::Shared(req) => {
|
GroupedPluginRequest::Shared(req) => GroupedPluginEvent::Handled(Some(build_shared_reply(
|
||||||
GroupedPluginEvent::Handled(Some(build_shared_reply(query_manager, req, context)))
|
query_manager,
|
||||||
}
|
body_store,
|
||||||
|
req,
|
||||||
|
context,
|
||||||
|
))),
|
||||||
GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),
|
GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),
|
||||||
GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),
|
GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),
|
||||||
}
|
}
|
||||||
@@ -196,6 +213,7 @@ pub fn handle_shared_plugin_event<'a>(
|
|||||||
|
|
||||||
fn build_shared_reply(
|
fn build_shared_reply(
|
||||||
query_manager: &QueryManager,
|
query_manager: &QueryManager,
|
||||||
|
body_store: &dyn ResponseBodyStore,
|
||||||
request: SharedRequest<'_>,
|
request: SharedRequest<'_>,
|
||||||
context: SharedPluginEventContext<'_>,
|
context: SharedPluginEventContext<'_>,
|
||||||
) -> InternalEventPayload {
|
) -> InternalEventPayload {
|
||||||
@@ -283,6 +301,30 @@ fn build_shared_reply(
|
|||||||
http_responses,
|
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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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) => {
|
SharedRequest::UpsertModel(req) => {
|
||||||
use AnyModel::*;
|
use AnyModel::*;
|
||||||
|
|
||||||
@@ -437,10 +479,26 @@ fn build_shared_reply(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
|
||||||
|
use std::cell::RefCell;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
|
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
|
||||||
use yaak_models::util::UpdateSource;
|
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) {
|
fn seed_query_manager() -> (QueryManager, TempDir) {
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
let db_path = temp_dir.path().join("db.sqlite");
|
let db_path = temp_dir.path().join("db.sqlite");
|
||||||
@@ -498,7 +556,7 @@ mod tests {
|
|||||||
let payload = InternalEventPayload::ListHttpRequestsRequest(
|
let payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||||
);
|
);
|
||||||
let result = handle_shared_plugin_event(
|
let result = dispatch(
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&payload,
|
&payload,
|
||||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||||
@@ -517,7 +575,7 @@ mod tests {
|
|||||||
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
|
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||||
);
|
);
|
||||||
let by_workspace = handle_shared_plugin_event(
|
let by_workspace = dispatch(
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&by_workspace_payload,
|
&by_workspace_payload,
|
||||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||||
@@ -536,7 +594,7 @@ mod tests {
|
|||||||
folder_id: Some("fl_test".to_string()),
|
folder_id: Some("fl_test".to_string()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let by_folder = handle_shared_plugin_event(
|
let by_folder = dispatch(
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&by_folder_payload,
|
&by_folder_payload,
|
||||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||||
@@ -559,7 +617,7 @@ mod tests {
|
|||||||
limit: Some(1),
|
limit: Some(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = handle_shared_plugin_event(
|
let result = dispatch(
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&payload,
|
&payload,
|
||||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||||
@@ -575,6 +633,104 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
fn upsert_and_delete_model_are_shared_handled() {
|
fn upsert_and_delete_model_are_shared_handled() {
|
||||||
let (query_manager, _temp_dir) = seed_query_manager();
|
let (query_manager, _temp_dir) = seed_query_manager();
|
||||||
@@ -590,7 +746,7 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let upsert_result = handle_shared_plugin_event(
|
let upsert_result = dispatch(
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&upsert_payload,
|
&upsert_payload,
|
||||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||||
@@ -609,7 +765,7 @@ mod tests {
|
|||||||
model: "http_request".to_string(),
|
model: "http_request".to_string(),
|
||||||
id: "rq_test".to_string(),
|
id: "rq_test".to_string(),
|
||||||
});
|
});
|
||||||
let delete_result = handle_shared_plugin_event(
|
let delete_result = dispatch(
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&delete_payload,
|
&delete_payload,
|
||||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||||
@@ -631,7 +787,7 @@ mod tests {
|
|||||||
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
|
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
|
||||||
label: "main".to_string(),
|
label: "main".to_string(),
|
||||||
});
|
});
|
||||||
let result = handle_shared_plugin_event(
|
let result = dispatch(
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&payload,
|
&payload,
|
||||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 the response stored no body.
|
||||||
|
///
|
||||||
|
/// A response that was never persisted (GraphQL introspection and other
|
||||||
|
/// ephemeral sends) has no row here at all, so it fails as "not found"
|
||||||
|
/// rather than reading as an empty 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.connect()
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
.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 an_unknown_response_fails() {
|
||||||
|
let (qm, _tmp, _id) = seed(Some(b"hi"));
|
||||||
|
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1
-1
@@ -17226,7 +17226,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-runtime-types": {
|
"packages/plugin-runtime-types": {
|
||||||
"name": "@yaakapp/api",
|
"name": "@yaakapp/api",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^24.0.13"
|
"@types/node": "^24.0.13"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@yaakapp/api",
|
"name": "@yaakapp/api",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"api-client",
|
"api-client",
|
||||||
"bruno-alternative",
|
"bruno-alternative",
|
||||||
|
|||||||
+42
-1
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import type {
|
|||||||
GetCookieValueResponse,
|
GetCookieValueResponse,
|
||||||
GetHttpRequestByIdRequest,
|
GetHttpRequestByIdRequest,
|
||||||
GetHttpRequestByIdResponse,
|
GetHttpRequestByIdResponse,
|
||||||
|
GetHttpResponseBodyInfoRequest,
|
||||||
JsonPrimitive,
|
JsonPrimitive,
|
||||||
ListCookieNamesResponse,
|
ListCookieNamesResponse,
|
||||||
ListFoldersRequest,
|
ListFoldersRequest,
|
||||||
@@ -65,6 +66,56 @@ type DynamicPromptFormRequest = Omit<PromptFormRequest, "inputs"> & {
|
|||||||
|
|
||||||
export type WorkspaceHandle = Pick<WorkspaceInfo, "id" | "name">;
|
export type WorkspaceHandle = Pick<WorkspaceInfo, "id" | "name">;
|
||||||
|
|
||||||
|
export interface ReadHttpResponseBodyOptions {
|
||||||
|
/**
|
||||||
|
* Refuse to buffer a body larger than this, in bytes. Defaults to 32 MiB.
|
||||||
|
* Pass `Infinity` to read whatever is there.
|
||||||
|
*/
|
||||||
|
maxBytes?: number;
|
||||||
|
|
||||||
|
/** Bytes to pull from the host at a time. Defaults to 1 MiB. */
|
||||||
|
chunkSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A response body, read back from wherever the host stored it.
|
||||||
|
*
|
||||||
|
* The accessors are named after `fetch`'s, but unlike `fetch` the body is not
|
||||||
|
* used up by reading it: these bytes are in durable storage, so every accessor
|
||||||
|
* can be called as many times as you like, in any order.
|
||||||
|
*/
|
||||||
|
export interface HttpResponseBody {
|
||||||
|
/** The response these bytes belong to. */
|
||||||
|
readonly responseId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many bytes are stored, which is not necessarily what the
|
||||||
|
* `Content-Length` header claimed. Zero when the response has no body.
|
||||||
|
*/
|
||||||
|
readonly contentLength: number;
|
||||||
|
|
||||||
|
/** The response's `Content-Type` header, verbatim, or null if it had none. */
|
||||||
|
readonly contentType: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body decoded to a string, using the charset from `contentType` and
|
||||||
|
* falling back to UTF-8. Throws if the body is over `maxBytes`.
|
||||||
|
*/
|
||||||
|
text(options?: ReadHttpResponseBodyOptions): Promise<string>;
|
||||||
|
|
||||||
|
/** `text()`, parsed as JSON. */
|
||||||
|
json<T = unknown>(options?: ReadHttpResponseBodyOptions): Promise<T>;
|
||||||
|
|
||||||
|
/** The raw bytes. Throws if the body is over `maxBytes`. */
|
||||||
|
arrayBuffer(options?: ReadHttpResponseBodyOptions): Promise<ArrayBuffer>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw bytes, a chunk at a time, so a body of any size can be read
|
||||||
|
* without holding all of it at once. Not subject to `maxBytes`.
|
||||||
|
*/
|
||||||
|
chunks(options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">): AsyncIterable<Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
clipboard: {
|
clipboard: {
|
||||||
copyText(text: string): Promise<void>;
|
copyText(text: string): Promise<void>;
|
||||||
@@ -129,6 +180,12 @@ export interface Context {
|
|||||||
};
|
};
|
||||||
httpResponse: {
|
httpResponse: {
|
||||||
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse["httpResponses"]>;
|
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse["httpResponses"]>;
|
||||||
|
/**
|
||||||
|
* Read a response's body by id. Where the host keeps the bytes — files on
|
||||||
|
* a desktop, rows in a database, somewhere else later — is not something a
|
||||||
|
* plugin sees or should depend on.
|
||||||
|
*/
|
||||||
|
body(args: GetHttpResponseBodyInfoRequest): Promise<HttpResponseBody>;
|
||||||
};
|
};
|
||||||
templates: {
|
templates: {
|
||||||
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
|
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import type { WorkspaceActionPlugin } from "./WorkspaceActionPlugin";
|
|||||||
|
|
||||||
export type { Context };
|
export type { Context };
|
||||||
export type { DynamicAuthenticationArg } from "./AuthenticationPlugin";
|
export type { DynamicAuthenticationArg } from "./AuthenticationPlugin";
|
||||||
export type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from "./Context";
|
export type {
|
||||||
|
CallPromptFormDynamicArgs,
|
||||||
|
DynamicPromptFormArg,
|
||||||
|
HttpResponseBody,
|
||||||
|
ReadHttpResponseBodyOptions,
|
||||||
|
} from "./Context";
|
||||||
export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin";
|
export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin";
|
||||||
export type { TemplateFunctionPlugin };
|
export type { TemplateFunctionPlugin };
|
||||||
export type { FolderActionPlugin } from "./FolderActionPlugin";
|
export type { FolderActionPlugin } from "./FolderActionPlugin";
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
GetCookieValueRequest,
|
GetCookieValueRequest,
|
||||||
GetCookieValueResponse,
|
GetCookieValueResponse,
|
||||||
GetHttpRequestByIdResponse,
|
GetHttpRequestByIdResponse,
|
||||||
|
GetHttpResponseBodyInfoResponse,
|
||||||
GetKeyValueResponse,
|
GetKeyValueResponse,
|
||||||
GrpcRequestAction,
|
GrpcRequestAction,
|
||||||
HttpAuthenticationAction,
|
HttpAuthenticationAction,
|
||||||
@@ -37,6 +38,7 @@ import type {
|
|||||||
PluginContext,
|
PluginContext,
|
||||||
PromptFormResponse,
|
PromptFormResponse,
|
||||||
PromptTextResponse,
|
PromptTextResponse,
|
||||||
|
ReadHttpResponseBodyChunkResponse,
|
||||||
RenderGrpcRequestResponse,
|
RenderGrpcRequestResponse,
|
||||||
RenderHttpRequestResponse,
|
RenderHttpRequestResponse,
|
||||||
SendHttpRequestResponse,
|
SendHttpRequestResponse,
|
||||||
@@ -49,6 +51,7 @@ import type {
|
|||||||
import { applyDynamicFormInput } from "./common";
|
import { applyDynamicFormInput } from "./common";
|
||||||
import { EventChannel } from "./EventChannel";
|
import { EventChannel } from "./EventChannel";
|
||||||
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
||||||
|
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||||
|
|
||||||
export interface PluginWorkerData {
|
export interface PluginWorkerData {
|
||||||
bootRequest: BootRequest;
|
bootRequest: BootRequest;
|
||||||
@@ -555,16 +558,24 @@ export class PluginInstance {
|
|||||||
#sendForReply<T extends Omit<InternalEventPayload, "type">>(
|
#sendForReply<T extends Omit<InternalEventPayload, "type">>(
|
||||||
context: PluginContext,
|
context: PluginContext,
|
||||||
payload: InternalEventPayload,
|
payload: InternalEventPayload,
|
||||||
|
// Off by default because a reply-shaped object with none of the expected
|
||||||
|
// fields is what every existing caller already copes with; turning it on
|
||||||
|
// for a new call is how that stops spreading.
|
||||||
|
{ throwOnError = false }: { throwOnError?: boolean } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
// 1. Build event to send
|
// 1. Build event to send
|
||||||
const eventToSend = this.#buildEventToSend(context, payload, null);
|
const eventToSend = this.#buildEventToSend(context, payload, null);
|
||||||
|
|
||||||
// 2. Spawn listener in background
|
// 2. Spawn listener in background
|
||||||
const promise = new Promise<T>((resolve) => {
|
const promise = new Promise<T>((resolve, reject) => {
|
||||||
const cb = (event: InternalEvent) => {
|
const cb = (event: InternalEvent) => {
|
||||||
if (event.replyId === eventToSend.id) {
|
if (event.replyId === eventToSend.id) {
|
||||||
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
|
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
|
||||||
const { type: _, ...payload } = event.payload;
|
const { type: _, ...payload } = event.payload;
|
||||||
|
if (throwOnError && event.payload.type === "error_response") {
|
||||||
|
reject(new Error(String((payload as { error?: string }).error ?? "Unknown error")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolve(payload as T);
|
resolve(payload as T);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -751,6 +762,29 @@ export class PluginInstance {
|
|||||||
);
|
);
|
||||||
return httpResponses;
|
return httpResponses;
|
||||||
},
|
},
|
||||||
|
body: async ({ responseId }) => {
|
||||||
|
const info = await this.#sendForReply<GetHttpResponseBodyInfoResponse>(
|
||||||
|
context,
|
||||||
|
{ type: "get_http_response_body_info_request", responseId },
|
||||||
|
{ throwOnError: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return createResponseBody(
|
||||||
|
{
|
||||||
|
responseId,
|
||||||
|
contentLength: info.contentLength,
|
||||||
|
contentType: info.contentType ?? null,
|
||||||
|
},
|
||||||
|
async (offset, length) => {
|
||||||
|
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
|
||||||
|
context,
|
||||||
|
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
|
||||||
|
{ throwOnError: true },
|
||||||
|
);
|
||||||
|
return decodeBase64Chunk(chunk.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
grpcRequest: {
|
grpcRequest: {
|
||||||
render: async (args) => {
|
render: async (args) => {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { HttpResponseBody, ReadHttpResponseBodyOptions } from "@yaakapp/api";
|
||||||
|
|
||||||
|
/** Bytes pulled from the host per round trip, when the caller doesn't say. */
|
||||||
|
const DEFAULT_CHUNK_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most a plugin buffers by default.
|
||||||
|
*
|
||||||
|
* Reading a body used to be unbounded, so any ceiling is an improvement; this
|
||||||
|
* one is set well above what an API returns and well below what makes the
|
||||||
|
* plugin runtime fall over. `chunks()` has no ceiling, and any caller that
|
||||||
|
* really wants the whole thing can raise `maxBytes`.
|
||||||
|
*/
|
||||||
|
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Fetch one window of body bytes from the host. */
|
||||||
|
export type ReadResponseBodyChunk = (offset: number, length: number) => Promise<Uint8Array>;
|
||||||
|
|
||||||
|
export interface ResponseBodyInfo {
|
||||||
|
responseId: string;
|
||||||
|
contentLength: number;
|
||||||
|
contentType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResponseBody(
|
||||||
|
info: ResponseBodyInfo,
|
||||||
|
readChunk: ReadResponseBodyChunk,
|
||||||
|
): HttpResponseBody {
|
||||||
|
const { responseId, contentLength, contentType } = info;
|
||||||
|
|
||||||
|
async function* chunks(
|
||||||
|
options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">,
|
||||||
|
): AsyncIterable<Uint8Array> {
|
||||||
|
const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));
|
||||||
|
let offset = 0;
|
||||||
|
// Bounded by the length the host reported, but a short read still ends it:
|
||||||
|
// the body may have been rewritten between the two calls.
|
||||||
|
while (offset < contentLength) {
|
||||||
|
const chunk = await readChunk(offset, Math.min(chunkSize, contentLength - offset));
|
||||||
|
if (chunk.byteLength === 0) return;
|
||||||
|
yield chunk;
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAll(accessor: string, options?: ReadHttpResponseBodyOptions) {
|
||||||
|
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
refuseIfTooBig(accessor, contentLength, maxBytes);
|
||||||
|
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for await (const chunk of chunks(options)) {
|
||||||
|
total += chunk.byteLength;
|
||||||
|
// The size the host reported is a claim about a moment ago, so check the
|
||||||
|
// bytes actually arriving too.
|
||||||
|
refuseIfTooBig(accessor, total, maxBytes);
|
||||||
|
parts.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
bytes.set(part, offset);
|
||||||
|
offset += part.byteLength;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
responseId,
|
||||||
|
contentLength,
|
||||||
|
contentType,
|
||||||
|
chunks,
|
||||||
|
async arrayBuffer(options) {
|
||||||
|
const bytes = await readAll("arrayBuffer", options);
|
||||||
|
return bytes.buffer as ArrayBuffer;
|
||||||
|
},
|
||||||
|
async text(options) {
|
||||||
|
return decodeBody(await readAll("text", options), contentType);
|
||||||
|
},
|
||||||
|
async json<T>(options?: ReadHttpResponseBodyOptions) {
|
||||||
|
return JSON.parse(decodeBody(await readAll("json", options), contentType)) as T;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function refuseIfTooBig(accessor: string, bytes: number, maxBytes: number) {
|
||||||
|
if (bytes <= maxBytes) return;
|
||||||
|
throw new Error(
|
||||||
|
`Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ` +
|
||||||
|
`${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode using the charset the response declared.
|
||||||
|
*
|
||||||
|
* Assuming UTF-8 mangles every response that isn't, and the header is right
|
||||||
|
* there. An unknown label is the one case worth guessing on, since the
|
||||||
|
* alternative is refusing to read a body we can very likely still read.
|
||||||
|
*/
|
||||||
|
function decodeBody(bytes: Uint8Array, contentType: string | null): string {
|
||||||
|
const charset = parseCharset(contentType);
|
||||||
|
if (charset != null) {
|
||||||
|
try {
|
||||||
|
return new TextDecoder(charset).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
// Not a label this runtime knows.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TextDecoder drops a leading BOM on its own.
|
||||||
|
return new TextDecoder("utf-8").decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCharset(contentType: string | null): string | null {
|
||||||
|
const match = contentType?.match(/;\s*charset\s*=\s*"?([^";]+)"?/i);
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === Infinity) return "unlimited";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a chunk that arrived as base64.
|
||||||
|
*
|
||||||
|
* The desktop transport is a WebSocket carrying JSON text frames, so bytes
|
||||||
|
* have to be spelled out. A host that can pass an ArrayBuffer along skips this.
|
||||||
|
*/
|
||||||
|
export function decodeBase64Chunk(data: string): Uint8Array {
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
const buf = Buffer.from(data, "base64");
|
||||||
|
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { createResponseBody, decodeBase64Chunk } from "../src/responseBody";
|
||||||
|
|
||||||
|
/** A store of bytes that records every window it was asked for. */
|
||||||
|
function fakeBody(bytes: Uint8Array, contentType: string | null) {
|
||||||
|
const reads: Array<[number, number]> = [];
|
||||||
|
const body = createResponseBody(
|
||||||
|
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType },
|
||||||
|
async (offset, length) => {
|
||||||
|
reads.push([offset, length]);
|
||||||
|
return bytes.slice(offset, offset + length);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { body, reads };
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8(text: string) {
|
||||||
|
return new TextEncoder().encode(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("response body", () => {
|
||||||
|
test("pulls a body in chunks and reassembles it", async () => {
|
||||||
|
const { body, reads } = fakeBody(utf8("abcdefghij"), "text/plain");
|
||||||
|
|
||||||
|
expect(await body.text({ chunkSize: 4 })).toEqual("abcdefghij");
|
||||||
|
expect(reads).toEqual([
|
||||||
|
[0, 4],
|
||||||
|
[4, 4],
|
||||||
|
[8, 2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can be read more than once, unlike fetch", async () => {
|
||||||
|
const { body } = fakeBody(utf8('{"a":1}'), "application/json");
|
||||||
|
|
||||||
|
expect(await body.text()).toEqual('{"a":1}');
|
||||||
|
expect(await body.json()).toEqual({ a: 1 });
|
||||||
|
expect(new Uint8Array(await body.arrayBuffer())).toEqual(utf8('{"a":1}'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes using the charset the response declared", async () => {
|
||||||
|
// "café naïve" as Latin-1, which is mojibake if read as UTF-8.
|
||||||
|
const latin1 = new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x20, 0x6e, 0x61, 0xef, 0x76, 0x65]);
|
||||||
|
|
||||||
|
const declared = fakeBody(latin1, "text/plain; charset=iso-8859-1");
|
||||||
|
expect(await declared.body.text()).toEqual("café naïve");
|
||||||
|
|
||||||
|
const undeclared = fakeBody(latin1, "text/plain");
|
||||||
|
expect(await undeclared.body.text()).not.toEqual("café naïve");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to UTF-8 for a charset the runtime doesn't know", async () => {
|
||||||
|
const { body } = fakeBody(utf8("hello"), "text/plain; charset=not-a-real-charset");
|
||||||
|
expect(await body.text()).toEqual("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops a UTF-8 BOM", async () => {
|
||||||
|
const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8('{"a":1}')]);
|
||||||
|
const { body } = fakeBody(withBom, "application/json");
|
||||||
|
|
||||||
|
expect(await body.text()).toEqual('{"a":1}');
|
||||||
|
expect(await body.json()).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses to buffer past maxBytes, and says what to do instead", async () => {
|
||||||
|
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
|
||||||
|
|
||||||
|
await expect(body.text({ maxBytes: 50 })).rejects.toThrow(/chunks\(\)/);
|
||||||
|
await expect(body.json({ maxBytes: 50 })).rejects.toThrow(/over the/);
|
||||||
|
await expect(body.arrayBuffer({ maxBytes: 50 })).rejects.toThrow(/arrayBuffer\(\)/);
|
||||||
|
|
||||||
|
// The ceiling is the caller's to raise.
|
||||||
|
expect(await body.text({ maxBytes: 100 })).toHaveLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams past maxBytes through chunks()", async () => {
|
||||||
|
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for await (const chunk of body.chunks({ chunkSize: 10 })) {
|
||||||
|
total += chunk.byteLength;
|
||||||
|
}
|
||||||
|
expect(total).toEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops early when the host runs out of bytes sooner than it claimed", async () => {
|
||||||
|
// contentLength says 100; the store only ever hands back 10.
|
||||||
|
const body = createResponseBody(
|
||||||
|
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain" },
|
||||||
|
async (offset) => (offset === 0 ? utf8("0123456789") : new Uint8Array()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await body.text()).toEqual("0123456789");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a response with no body reads as empty", async () => {
|
||||||
|
const { body, reads } = fakeBody(new Uint8Array(), "application/json");
|
||||||
|
|
||||||
|
expect(body.contentLength).toEqual(0);
|
||||||
|
expect(await body.text()).toEqual("");
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps binary bytes intact", async () => {
|
||||||
|
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]);
|
||||||
|
const { body } = fakeBody(png, "image/png");
|
||||||
|
|
||||||
|
expect(new Uint8Array(await body.arrayBuffer({ chunkSize: 3 }))).toEqual(png);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeBase64Chunk", () => {
|
||||||
|
test("round-trips arbitrary bytes", () => {
|
||||||
|
const bytes = new Uint8Array([0, 1, 127, 128, 254, 255]);
|
||||||
|
const base64 = Buffer.from(bytes).toString("base64");
|
||||||
|
expect(decodeBase64Chunk(base64)).toEqual(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes an empty chunk", () => {
|
||||||
|
expect(decodeBase64Chunk("")).toEqual(new Uint8Array());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import type {
|
import type {
|
||||||
CallTemplateFunctionArgs,
|
CallTemplateFunctionArgs,
|
||||||
Context,
|
Context,
|
||||||
@@ -196,17 +195,8 @@ export const plugin: PluginDefinition = {
|
|||||||
});
|
});
|
||||||
if (response == null) return null;
|
if (response == null) return null;
|
||||||
|
|
||||||
if (response.bodyPath == null) {
|
const body = await readResponseBody(ctx, response);
|
||||||
return null;
|
if (body == null) return null;
|
||||||
}
|
|
||||||
|
|
||||||
const BOM = "\ufeff";
|
|
||||||
let body: string;
|
|
||||||
try {
|
|
||||||
body = readFileSync(response.bodyPath, "utf-8").replace(BOM, "");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: JSONPathResult =
|
const result: JSONPathResult =
|
||||||
@@ -261,23 +251,29 @@ export const plugin: PluginDefinition = {
|
|||||||
});
|
});
|
||||||
if (response == null) return null;
|
if (response == null) return null;
|
||||||
|
|
||||||
if (response.bodyPath == null) {
|
return await readResponseBody(ctx, response);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: string;
|
|
||||||
try {
|
|
||||||
body = readFileSync(response.bodyPath, "utf-8");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response's body as text, or null when there is nothing to read.
|
||||||
|
*
|
||||||
|
* The host is asked for it by response id, so this works wherever the bytes
|
||||||
|
* happen to live. A body over the runtime's size limit throws rather than
|
||||||
|
* coming back empty, since a template silently rendering to nothing is worse
|
||||||
|
* than one that says why.
|
||||||
|
*/
|
||||||
|
async function readResponseBody(ctx: Context, response: HttpResponse): Promise<string | null> {
|
||||||
|
if (!response.id) return null;
|
||||||
|
|
||||||
|
const body = await ctx.httpResponse.body({ responseId: response.id });
|
||||||
|
if (body.contentLength === 0) return null;
|
||||||
|
|
||||||
|
return await body.text();
|
||||||
|
}
|
||||||
|
|
||||||
async function getResponse(
|
async function getResponse(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user