mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 00:52:09 +02:00
Return the body of a send that saved nothing
Replaces the response-directory fallback with what the frontend already does for ephemeral sends: the engine hands the body back, because it is the only copy. Guessing at a file named for an id was the store reaching around its own abstraction, and it is exactly what must not survive the move to blob storage. The send reply carries the bytes when the engine returned them, and the runtime holds them for the rest of the call that sent them, so ctx.httpResponse.body() answers for a saved and an unsaved response the same way. Unsaved ones now report a real contentType too, taken from the response the send already handed back.
This commit is contained in:
@@ -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;
|
||||
@@ -132,7 +134,7 @@ async fn build_plugin_reply(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
&host_context.query_manager,
|
||||
&FileResponseBodyStore::new(&host_context.query_manager, &host_context.response_dir),
|
||||
&FileResponseBodyStore::new(&host_context.query_manager),
|
||||
&event.payload,
|
||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||
) {
|
||||
@@ -225,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;
|
||||
@@ -53,13 +55,9 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
.and_then(|window| workspace_from_window(&window).map(|workspace| workspace.id))
|
||||
});
|
||||
|
||||
// Same directory the engine writes bodies into, so responses it never
|
||||
// recorded are still readable by id.
|
||||
let response_dir = app_handle.path().app_data_dir()?.join("responses");
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
app_handle.db_manager().inner(),
|
||||
&FileResponseBodyStore::new(app_handle.db_manager().inner(), &response_dir),
|
||||
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
||||
&event.payload,
|
||||
SharedPluginEventContext {
|
||||
plugin_name: &plugin_name,
|
||||
@@ -319,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) => {
|
||||
|
||||
+10
-1
@@ -557,7 +557,16 @@ export type RenderPurpose = "send" | "preview";
|
||||
|
||||
export type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>, };
|
||||
|
||||
export type SendHttpRequestResponse = { httpResponse: HttpResponse, };
|
||||
export type SendHttpRequestResponse = { httpResponse: 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`.
|
||||
*/
|
||||
body?: string | null, };
|
||||
|
||||
export type SetKeyValueRequest = { key: string, value: string, };
|
||||
|
||||
|
||||
@@ -294,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)]
|
||||
|
||||
@@ -481,7 +481,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
@@ -494,7 +493,7 @@ mod tests {
|
||||
) -> GroupedPluginEvent<'a> {
|
||||
handle_shared_plugin_event(
|
||||
query_manager,
|
||||
&FileResponseBodyStore::new(query_manager, Path::new("/nonexistent-response-dir")),
|
||||
&FileResponseBodyStore::new(query_manager),
|
||||
payload,
|
||||
context,
|
||||
)
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
//! 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 std::path::{Path, PathBuf};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
|
||||
/// The most bytes one read will hand back, however much was asked for.
|
||||
@@ -44,60 +46,34 @@ pub trait ResponseBodyStore {
|
||||
/// filesystem holds it.
|
||||
pub struct FileResponseBodyStore<'a> {
|
||||
query_manager: &'a QueryManager,
|
||||
response_dir: &'a Path,
|
||||
}
|
||||
|
||||
impl<'a> FileResponseBodyStore<'a> {
|
||||
pub fn new(query_manager: &'a QueryManager, response_dir: &'a Path) -> Self {
|
||||
Self { query_manager, response_dir }
|
||||
pub fn new(query_manager: &'a QueryManager) -> Self {
|
||||
Self { query_manager }
|
||||
}
|
||||
|
||||
/// The file backing a response, or `None` when it stored no body.
|
||||
///
|
||||
/// Most responses have a row that names their file. A response sent with no
|
||||
/// request behind it — the plugin `ctx.httpRequest.send` of an ad-hoc
|
||||
/// request, GraphQL introspection — is ephemeral: the engine gives it an id
|
||||
/// and writes its body under that id, but never records it. Those bodies are
|
||||
/// still the caller's to read, so fall back to where the engine puts them.
|
||||
fn body_path(&self, response_id: &str) -> Result<Option<PathBuf>> {
|
||||
match self.query_manager.connect().get_http_response(response_id) {
|
||||
Ok(response) => Ok(response.body_path.map(PathBuf::from)),
|
||||
Err(err) => match self.ephemeral_path(response_id) {
|
||||
Some(path) if path.is_file() => Ok(Some(path)),
|
||||
_ => Err(err.into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an unrecorded response's body would be, if the id can name one.
|
||||
///
|
||||
/// Ids arrive from a plugin, so only the shape the engine actually generates
|
||||
/// is accepted; that leaves nothing that could climb out of the response
|
||||
/// directory.
|
||||
fn ephemeral_path(&self, response_id: &str) -> Option<PathBuf> {
|
||||
let looks_generated = response_id.starts_with("rs_")
|
||||
&& response_id.len() > 3
|
||||
&& response_id[3..].chars().all(|c| c.is_ascii_alphanumeric());
|
||||
|
||||
looks_generated.then(|| self.response_dir.join(response_id))
|
||||
/// 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> {
|
||||
// The headers live on the row, so an ephemeral response has none to
|
||||
// give. Readers that need its charset have the response object the send
|
||||
// handed back.
|
||||
let content_type = match self.query_manager.connect().get_http_response(response_id) {
|
||||
Ok(response) => response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone()),
|
||||
Err(_) => None,
|
||||
};
|
||||
let response = self.query_manager.connect().get_http_response(response_id)?;
|
||||
|
||||
let content_length = match self.body_path(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,
|
||||
};
|
||||
@@ -132,10 +108,6 @@ mod tests {
|
||||
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
fn store<'a>(qm: &'a QueryManager, dir: &'a TempDir) -> FileResponseBodyStore<'a> {
|
||||
FileResponseBodyStore::new(qm, dir.path())
|
||||
}
|
||||
|
||||
fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
@@ -196,7 +168,7 @@ mod tests {
|
||||
#[test]
|
||||
fn info_reports_stored_size_and_content_type() {
|
||||
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
||||
let info = store(&qm, &_tmp).info(&id).unwrap();
|
||||
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"));
|
||||
}
|
||||
@@ -204,7 +176,7 @@ mod tests {
|
||||
#[test]
|
||||
fn chunks_cover_the_body_and_stop_short_at_the_end() {
|
||||
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
||||
let store = store(&qm, &_tmp);
|
||||
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());
|
||||
@@ -215,7 +187,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_response_with_no_body_is_empty_not_an_error() {
|
||||
let (qm, _tmp, id) = seed(None);
|
||||
let store = store(&qm, &_tmp);
|
||||
let store = FileResponseBodyStore::new(&qm);
|
||||
assert_eq!(store.info(&id).unwrap().content_length, 0);
|
||||
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
|
||||
}
|
||||
@@ -223,30 +195,16 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unknown_response_fails() {
|
||||
let (qm, _tmp, _id) = seed(Some(b"hi"));
|
||||
assert!(store(&qm, &_tmp).info("rs_nope").is_err());
|
||||
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ephemeral_body_is_readable_by_id_without_a_row() {
|
||||
// What a plugin's ad-hoc `ctx.httpRequest.send` leaves behind: a file
|
||||
// named for a response the engine never recorded.
|
||||
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();
|
||||
|
||||
let info = store(&qm, &tmp).info("rs_ephemeral1").unwrap();
|
||||
assert_eq!(info.content_length, 16);
|
||||
// No row means no headers to report a charset from.
|
||||
assert_eq!(info.content_type, None);
|
||||
assert_eq!(store(&qm, &tmp).read_chunk("rs_ephemeral1", 0, 6).unwrap(), b"access");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_that_is_not_a_generated_one_cannot_name_a_file() {
|
||||
let (qm, tmp, _id) = seed(Some(b"hi"));
|
||||
std::fs::write(tmp.path().join("secrets"), b"nope").unwrap();
|
||||
|
||||
for id in ["secrets", "../secrets", "rs_../secrets", "rs_", "/etc/hosts"] {
|
||||
assert!(store(&qm, &tmp).info(id).is_err(), "{id} should not resolve");
|
||||
}
|
||||
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,
|
||||
|
||||
+10
-1
@@ -557,7 +557,16 @@ export type RenderPurpose = "send" | "preview";
|
||||
|
||||
export type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>, };
|
||||
|
||||
export type SendHttpRequestResponse = { httpResponse: HttpResponse, };
|
||||
export type SendHttpRequestResponse = { httpResponse: 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`.
|
||||
*/
|
||||
body?: string | null, };
|
||||
|
||||
export type SetKeyValueRequest = { key: string, value: string, };
|
||||
|
||||
|
||||
@@ -184,6 +184,11 @@ export interface Context {
|
||||
* 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.
|
||||
*
|
||||
* Works for any response the host saved, and for one that `send` returned
|
||||
* without saving — those are held for the rest of the call that sent them,
|
||||
* since nothing else has a copy. Reaching for an unsaved response's id in a
|
||||
* later call throws, because by then its bytes are gone.
|
||||
*/
|
||||
body(args: GetHttpResponseBodyInfoRequest): Promise<HttpResponseBody>;
|
||||
};
|
||||
|
||||
@@ -609,6 +609,14 @@ export class PluginInstance {
|
||||
}
|
||||
|
||||
#newCtx(context: PluginContext): Context {
|
||||
// Bodies of sends that saved nothing, keyed by the response id they were
|
||||
// handed back with.
|
||||
//
|
||||
// A ctx is built per incoming call, so these last exactly as long as the
|
||||
// plugin invocation that produced them — which is the whole life of an
|
||||
// unsaved response. Nothing else can reach one: the host has no row for it.
|
||||
const unsavedBodies = new Map<string, { bytes: Uint8Array; contentType: string | null }>();
|
||||
|
||||
const _windowInfo = async () => {
|
||||
if (context.label == null) {
|
||||
throw new Error("Can't get window context without an active window");
|
||||
@@ -763,6 +771,18 @@ export class PluginInstance {
|
||||
return httpResponses;
|
||||
},
|
||||
body: async ({ responseId }) => {
|
||||
const unsaved = unsavedBodies.get(responseId);
|
||||
if (unsaved != null) {
|
||||
return createResponseBody(
|
||||
{
|
||||
responseId,
|
||||
contentLength: unsaved.bytes.byteLength,
|
||||
contentType: unsaved.contentType,
|
||||
},
|
||||
async (offset, length) => unsaved.bytes.slice(offset, offset + length),
|
||||
);
|
||||
}
|
||||
|
||||
const info = await this.#sendForReply<GetHttpResponseBodyInfoResponse>(
|
||||
context,
|
||||
{ type: "get_http_response_body_info_request", responseId },
|
||||
@@ -816,10 +836,23 @@ 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 send with no request behind it saves nothing, so this reply is
|
||||
// the only copy of its body. Hold it so ctx.httpResponse.body() can
|
||||
// answer for it the same way it answers for a saved response.
|
||||
if (body != null) {
|
||||
unsavedBodies.set(httpResponse.id, {
|
||||
bytes: decodeBase64Chunk(body),
|
||||
contentType:
|
||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")?.value ??
|
||||
null,
|
||||
});
|
||||
}
|
||||
|
||||
return httpResponse;
|
||||
},
|
||||
render: async (args) => {
|
||||
|
||||
Reference in New Issue
Block a user