mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 00:52:09 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8413c441 | ||
|
|
0e14625e62 | ||
|
|
8c72538102 | ||
|
|
ffa6a610b8 | ||
|
|
8b031db685 | ||
|
|
96c8a95094 |
Generated
+1
@@ -11196,6 +11196,7 @@ name = "yaak"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::context::CliExecutionContext;
|
||||
use arboard::Clipboard;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use console::Term;
|
||||
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
||||
use serde_json::Value;
|
||||
@@ -12,6 +14,7 @@ use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
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_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
@@ -131,6 +134,7 @@ async fn build_plugin_reply(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
&host_context.query_manager,
|
||||
&FileResponseBodyStore::new(&host_context.query_manager),
|
||||
&event.payload,
|
||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||
) {
|
||||
@@ -223,7 +227,15 @@ async fn build_plugin_reply(
|
||||
.await
|
||||
{
|
||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||
SendHttpRequestResponse { http_response: result.response },
|
||||
SendHttpRequestResponse {
|
||||
http_response: result.response,
|
||||
// Nothing saved this body, so the reply is the only
|
||||
// place the plugin can get it.
|
||||
body: result
|
||||
.response_body
|
||||
.returned_bytes()
|
||||
.map(|b| BASE64_STANDARD.encode(b)),
|
||||
},
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to send HTTP request in CLI: {err}"),
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{
|
||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||
workspace_from_window,
|
||||
};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use chrono::Utc;
|
||||
use log::error;
|
||||
use std::sync::Arc;
|
||||
@@ -16,6 +18,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_models::models::{HttpResponse, Plugin};
|
||||
@@ -54,6 +57,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
app_handle.db_manager().inner(),
|
||||
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
||||
&event.payload,
|
||||
SharedPluginEventContext {
|
||||
plugin_name: &plugin_name,
|
||||
@@ -313,8 +317,13 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// An ad-hoc request saves nothing, so the engine hands the body
|
||||
// back and this reply is the only place the plugin can get it.
|
||||
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
|
||||
|
||||
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||
http_response: http_response.response,
|
||||
body,
|
||||
})))
|
||||
}
|
||||
HostRequest::OpenWindow(req) => {
|
||||
|
||||
+52
-2
File diff suppressed because one or more lines are too long
@@ -171,6 +171,12 @@ pub enum InternalEventPayload {
|
||||
|
||||
FindHttpResponsesRequest(FindHttpResponsesRequest),
|
||||
FindHttpResponsesResponse(FindHttpResponsesResponse),
|
||||
|
||||
GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest),
|
||||
GetHttpResponseBodyInfoResponse(GetHttpResponseBodyInfoResponse),
|
||||
ReadHttpResponseBodyChunkRequest(ReadHttpResponseBodyChunkRequest),
|
||||
ReadHttpResponseBodyChunkResponse(ReadHttpResponseBodyChunkResponse),
|
||||
|
||||
ListHttpRequestsRequest(ListHttpRequestsRequest),
|
||||
ListHttpRequestsResponse(ListHttpRequestsResponse),
|
||||
ListFoldersRequest(ListFoldersRequest),
|
||||
@@ -288,6 +294,15 @@ pub struct SendHttpRequestRequest {
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct SendHttpRequestResponse {
|
||||
pub http_response: HttpResponse,
|
||||
|
||||
/// The body, base64, when the send saved nothing.
|
||||
///
|
||||
/// A request with no id behind it produces a response the model store never
|
||||
/// sees, so it cannot be read back by id later the way a saved one can.
|
||||
/// This is the only copy of it. `None` means the body was stored and should
|
||||
/// be read with `read_http_response_body_chunk_request`.
|
||||
#[ts(optional = nullable)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
@@ -1413,6 +1428,62 @@ pub struct FindHttpResponsesResponse {
|
||||
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)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod export;
|
||||
pub mod import;
|
||||
pub mod plugin_events;
|
||||
pub mod render;
|
||||
pub mod response_body;
|
||||
pub mod send;
|
||||
|
||||
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::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
@@ -5,12 +8,14 @@ use yaak_plugins::events::{
|
||||
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
||||
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
|
||||
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
|
||||
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
|
||||
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
|
||||
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
|
||||
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
|
||||
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
|
||||
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> {
|
||||
@@ -40,6 +45,8 @@ pub enum SharedRequest<'a> {
|
||||
ListFolders(&'a ListFoldersRequest),
|
||||
ListHttpRequests(&'a ListHttpRequestsRequest),
|
||||
FindHttpResponses(&'a FindHttpResponsesRequest),
|
||||
GetHttpResponseBodyInfo(&'a GetHttpResponseBodyInfoRequest),
|
||||
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
|
||||
UpsertModel(&'a UpsertModelRequest),
|
||||
DeleteModel(&'a DeleteModelRequest),
|
||||
}
|
||||
@@ -136,6 +143,12 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
||||
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))
|
||||
}
|
||||
@@ -182,13 +195,17 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
||||
|
||||
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, req, context)))
|
||||
}
|
||||
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),
|
||||
}
|
||||
@@ -196,6 +213,7 @@ pub fn handle_shared_plugin_event<'a>(
|
||||
|
||||
fn build_shared_reply(
|
||||
query_manager: &QueryManager,
|
||||
body_store: &dyn ResponseBodyStore,
|
||||
request: SharedRequest<'_>,
|
||||
context: SharedPluginEventContext<'_>,
|
||||
) -> InternalEventPayload {
|
||||
@@ -283,6 +301,30 @@ fn build_shared_reply(
|
||||
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) => {
|
||||
use AnyModel::*;
|
||||
|
||||
@@ -437,10 +479,26 @@ fn build_shared_reply(
|
||||
#[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");
|
||||
@@ -498,7 +556,7 @@ mod tests {
|
||||
let payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||
);
|
||||
let result = handle_shared_plugin_event(
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
@@ -517,7 +575,7 @@ mod tests {
|
||||
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||
);
|
||||
let by_workspace = handle_shared_plugin_event(
|
||||
let by_workspace = dispatch(
|
||||
&query_manager,
|
||||
&by_workspace_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
@@ -536,7 +594,7 @@ mod tests {
|
||||
folder_id: Some("fl_test".to_string()),
|
||||
},
|
||||
);
|
||||
let by_folder = handle_shared_plugin_event(
|
||||
let by_folder = dispatch(
|
||||
&query_manager,
|
||||
&by_folder_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
@@ -559,7 +617,7 @@ mod tests {
|
||||
limit: Some(1),
|
||||
});
|
||||
|
||||
let result = handle_shared_plugin_event(
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
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]
|
||||
fn upsert_and_delete_model_are_shared_handled() {
|
||||
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,
|
||||
&upsert_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
@@ -609,7 +765,7 @@ mod tests {
|
||||
model: "http_request".to_string(),
|
||||
id: "rq_test".to_string(),
|
||||
});
|
||||
let delete_result = handle_shared_plugin_event(
|
||||
let delete_result = dispatch(
|
||||
&query_manager,
|
||||
&delete_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
@@ -631,7 +787,7 @@ mod tests {
|
||||
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
|
||||
label: "main".to_string(),
|
||||
});
|
||||
let result = handle_shared_plugin_event(
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! 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::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 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 })
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -354,6 +354,19 @@ pub enum ResponseBody {
|
||||
Returned(Vec<u8>),
|
||||
}
|
||||
|
||||
impl ResponseBody {
|
||||
/// The bytes, when this is the only copy of them.
|
||||
///
|
||||
/// Stored and streamed bodies belong to whoever holds them; only `Returned`
|
||||
/// has to travel back to the caller.
|
||||
pub fn returned_bytes(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
ResponseBody::Returned(bytes) => Some(bytes),
|
||||
ResponseBody::Stored | ResponseBody::Streamed => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SendHttpRequestResult {
|
||||
pub rendered_request: HttpRequest,
|
||||
pub response: HttpResponse,
|
||||
|
||||
Generated
+1
-1
@@ -17226,7 +17226,7 @@
|
||||
},
|
||||
"packages/plugin-runtime-types": {
|
||||
"name": "@yaakapp/api",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.13"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp/api",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"keywords": [
|
||||
"api-client",
|
||||
"bruno-alternative",
|
||||
|
||||
+52
-2
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import type {
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdRequest,
|
||||
GetHttpRequestByIdResponse,
|
||||
GetHttpResponseBodyInfoRequest,
|
||||
JsonPrimitive,
|
||||
ListCookieNamesResponse,
|
||||
ListFoldersRequest,
|
||||
@@ -22,12 +23,11 @@ import type {
|
||||
RenderHttpRequestRequest,
|
||||
RenderHttpRequestResponse,
|
||||
SendHttpRequestRequest,
|
||||
SendHttpRequestResponse,
|
||||
ShowToastRequest,
|
||||
TemplateRenderRequest,
|
||||
WorkspaceInfo,
|
||||
} from "../bindings/gen_events.ts";
|
||||
import type { Folder, HttpRequest } from "../bindings/gen_models.ts";
|
||||
import type { Folder, HttpRequest, HttpResponse } from "../bindings/gen_models.ts";
|
||||
import type { JsonValue } from "../bindings/serde_json/JsonValue";
|
||||
import type { MaybePromise } from "../helpers";
|
||||
|
||||
@@ -65,6 +65,70 @@ type DynamicPromptFormRequest = Omit<PromptFormRequest, "inputs"> & {
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/** What a send came back with. */
|
||||
export interface SentHttpRequest {
|
||||
httpResponse: HttpResponse;
|
||||
|
||||
/**
|
||||
* The response's body.
|
||||
*
|
||||
* Handed over here rather than looked up later, because a request with no id
|
||||
* is not saved and this is the only copy of its body. Reading it is the same
|
||||
* either way, so nothing has to know which kind of send it made.
|
||||
*/
|
||||
body: HttpResponseBody;
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
clipboard: {
|
||||
copyText(text: string): Promise<void>;
|
||||
@@ -101,7 +165,13 @@ export interface Context {
|
||||
render(args: RenderGrpcRequestRequest): Promise<RenderGrpcRequestResponse["grpcRequest"]>;
|
||||
};
|
||||
httpRequest: {
|
||||
send(args: SendHttpRequestRequest): Promise<SendHttpRequestResponse["httpResponse"]>;
|
||||
/**
|
||||
* Send a request and wait for the response and its body.
|
||||
*
|
||||
* The body comes back with the response because a request with no id is
|
||||
* never saved, and there would be nothing to look up afterwards.
|
||||
*/
|
||||
send(args: SendHttpRequestRequest): Promise<SentHttpRequest>;
|
||||
getById(args: GetHttpRequestByIdRequest): Promise<GetHttpRequestByIdResponse["httpRequest"]>;
|
||||
render(args: RenderHttpRequestRequest): Promise<RenderHttpRequestResponse["httpRequest"]>;
|
||||
list(args?: ListHttpRequestsRequest): Promise<ListHttpRequestsResponse["httpRequests"]>;
|
||||
@@ -129,6 +199,15 @@ export interface Context {
|
||||
};
|
||||
httpResponse: {
|
||||
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse["httpResponses"]>;
|
||||
/**
|
||||
* Read a saved 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.
|
||||
*
|
||||
* Ids come from `find`. A response that was never saved has none to look
|
||||
* up, so its body arrives with the send that made it instead.
|
||||
*/
|
||||
body(args: GetHttpResponseBodyInfoRequest): Promise<HttpResponseBody>;
|
||||
};
|
||||
templates: {
|
||||
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
|
||||
|
||||
@@ -13,7 +13,13 @@ import type { WorkspaceActionPlugin } from "./WorkspaceActionPlugin";
|
||||
|
||||
export type { Context };
|
||||
export type { DynamicAuthenticationArg } from "./AuthenticationPlugin";
|
||||
export type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from "./Context";
|
||||
export type {
|
||||
CallPromptFormDynamicArgs,
|
||||
DynamicPromptFormArg,
|
||||
HttpResponseBody,
|
||||
ReadHttpResponseBodyOptions,
|
||||
SentHttpRequest,
|
||||
} from "./Context";
|
||||
export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin";
|
||||
export type { TemplateFunctionPlugin };
|
||||
export type { FolderActionPlugin } from "./FolderActionPlugin";
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
GetCookieValueRequest,
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdResponse,
|
||||
GetHttpResponseBodyInfoResponse,
|
||||
GetKeyValueResponse,
|
||||
GrpcRequestAction,
|
||||
HttpAuthenticationAction,
|
||||
@@ -37,6 +38,7 @@ import type {
|
||||
PluginContext,
|
||||
PromptFormResponse,
|
||||
PromptTextResponse,
|
||||
ReadHttpResponseBodyChunkResponse,
|
||||
RenderGrpcRequestResponse,
|
||||
RenderHttpRequestResponse,
|
||||
SendHttpRequestResponse,
|
||||
@@ -49,6 +51,7 @@ import type {
|
||||
import { applyDynamicFormInput } from "./common";
|
||||
import { EventChannel } from "./EventChannel";
|
||||
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
||||
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||
|
||||
export interface PluginWorkerData {
|
||||
bootRequest: BootRequest;
|
||||
@@ -555,16 +558,24 @@ export class PluginInstance {
|
||||
#sendForReply<T extends Omit<InternalEventPayload, "type">>(
|
||||
context: PluginContext,
|
||||
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> {
|
||||
// 1. Build event to send
|
||||
const eventToSend = this.#buildEventToSend(context, payload, null);
|
||||
|
||||
// 2. Spawn listener in background
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
const cb = (event: InternalEvent) => {
|
||||
if (event.replyId === eventToSend.id) {
|
||||
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -598,6 +609,27 @@ export class PluginInstance {
|
||||
}
|
||||
|
||||
#newCtx(context: PluginContext): Context {
|
||||
/** Read a body the host has stored, a chunk at a time. */
|
||||
const storedBody = async (responseId: string) => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const _windowInfo = async () => {
|
||||
if (context.label == null) {
|
||||
throw new Error("Can't get window context without an active window");
|
||||
@@ -751,6 +783,7 @@ export class PluginInstance {
|
||||
);
|
||||
return httpResponses;
|
||||
},
|
||||
body: ({ responseId }) => storedBody(responseId),
|
||||
},
|
||||
grpcRequest: {
|
||||
render: async (args) => {
|
||||
@@ -782,11 +815,35 @@ export class PluginInstance {
|
||||
type: "send_http_request_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpResponse } = await this.#sendForReply<SendHttpRequestResponse>(
|
||||
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
// A failed send has no response to hand back, and reading `.body`
|
||||
// off nothing would bury the host's reason for failing.
|
||||
{ throwOnError: true },
|
||||
);
|
||||
return httpResponse;
|
||||
|
||||
// A send with no request behind it saves nothing, so the reply
|
||||
// carries the only copy of its body. A saved one is read back from
|
||||
// the host like any other. Callers get the same thing either way.
|
||||
if (body == null) {
|
||||
return { httpResponse, body: await storedBody(httpResponse.id) };
|
||||
}
|
||||
|
||||
const bytes = decodeBase64Chunk(body);
|
||||
return {
|
||||
httpResponse,
|
||||
body: createResponseBody(
|
||||
{
|
||||
responseId: httpResponse.id,
|
||||
contentLength: bytes.byteLength,
|
||||
contentType:
|
||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
|
||||
?.value ?? null,
|
||||
},
|
||||
async (offset, length) => bytes.slice(offset, offset + length),
|
||||
),
|
||||
};
|
||||
},
|
||||
render: async (args) => {
|
||||
const payload = {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
throw new Error(`HTTP request with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const response = await workspaceCtx.yaak.httpRequest.send({ httpRequest });
|
||||
const { httpResponse: response } = await workspaceCtx.yaak.httpRequest.send({ httpRequest });
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
||||
@@ -67,7 +67,7 @@ export const plugin: PluginDefinition = {
|
||||
|
||||
const type1 = ntlm.createType1Message(options);
|
||||
|
||||
const negotiateResponse = await ctx.httpRequest.send({
|
||||
const { httpResponse: negotiateResponse } = await ctx.httpRequest.send({
|
||||
httpRequest: {
|
||||
method,
|
||||
url,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { Context, HttpRequest, HttpUrlParameter } from "@yaakapp/api";
|
||||
import type { AccessTokenRawResponse } from "./store";
|
||||
|
||||
@@ -57,7 +56,7 @@ export async function fetchAccessToken(
|
||||
}
|
||||
|
||||
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
|
||||
const resp = await ctx.httpRequest.send({ httpRequest });
|
||||
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
|
||||
|
||||
console.log("[oauth2] Got access token response", resp.status);
|
||||
|
||||
@@ -65,7 +64,8 @@ export async function fetchAccessToken(
|
||||
throw new Error(`Failed to fetch access token: ${resp.error}`);
|
||||
}
|
||||
|
||||
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
|
||||
// Empty when the response had no body, which parses to {} below.
|
||||
const body = await responseBody.text();
|
||||
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw new Error(`Failed to fetch access token with status=${resp.status} and body=${body}`);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { Context, HttpRequest } from "@yaakapp/api";
|
||||
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from "./store";
|
||||
import { deleteToken, getToken, storeToken } from "./store";
|
||||
@@ -71,7 +70,7 @@ export async function getOrRefreshAccessToken(
|
||||
}
|
||||
|
||||
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
|
||||
const resp = await ctx.httpRequest.send({ httpRequest });
|
||||
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
|
||||
|
||||
if (resp.error) {
|
||||
throw new Error(`Failed to refresh access token: ${resp.error}`);
|
||||
@@ -85,7 +84,8 @@ export async function getOrRefreshAccessToken(
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
|
||||
// Empty when the response had no body, which parses to {} below.
|
||||
const body = await responseBody.text();
|
||||
|
||||
console.log("[oauth2] Got refresh token response", resp.status);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type {
|
||||
CallTemplateFunctionArgs,
|
||||
Context,
|
||||
@@ -196,17 +195,8 @@ export const plugin: PluginDefinition = {
|
||||
});
|
||||
if (response == null) return null;
|
||||
|
||||
if (response.bodyPath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const BOM = "\ufeff";
|
||||
let body: string;
|
||||
try {
|
||||
body = readFileSync(response.bodyPath, "utf-8").replace(BOM, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const body = await readResponseBody(ctx, response);
|
||||
if (body == null) return null;
|
||||
|
||||
try {
|
||||
const result: JSONPathResult =
|
||||
@@ -261,23 +251,32 @@ export const plugin: PluginDefinition = {
|
||||
});
|
||||
if (response == null) return null;
|
||||
|
||||
if (response.bodyPath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = readFileSync(response.bodyPath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return body;
|
||||
return await readResponseBody(ctx, response);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 — including responses it never recorded, which still get an
|
||||
* id. 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> {
|
||||
// Belt and braces: everything reaching here came from find() or send() and so
|
||||
// has an id. An empty one would just be an unreadable id.
|
||||
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(
|
||||
ctx: Context,
|
||||
{
|
||||
@@ -320,7 +319,7 @@ async function getResponse(
|
||||
// Explicitly render the request before send (instead of relying on send() to render) so that we can
|
||||
// preserve the render purpose.
|
||||
const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });
|
||||
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
|
||||
response = (await ctx.httpRequest.send({ httpRequest: renderedHttpRequest })).httpResponse;
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
Reference in New Issue
Block a user