diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx
index 645de078..9df20748 100644
--- a/apps/yaak-client/components/HttpResponsePane.tsx
+++ b/apps/yaak-client/components/HttpResponsePane.tsx
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
+import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
Component,
}: {
response: HttpResponse;
- Component: ComponentType<{ bodyPath: string }>;
+ Component: ComponentType<{ url: string }>;
}) {
- if (response.bodyPath === null) {
- return
Empty response body
;
- }
+ // Wait until the response has been fully-downloaded before asking for it
+ const complete = response.state === "closed";
+ const bodyUrl = useResponseBodyUrl(complete ? response : null);
- // Wait until the response has been fully-downloaded
- if (response.state !== "closed") {
+ if (!complete || bodyUrl.isPending) {
return (
@@ -424,7 +424,15 @@ function EnsureCompleteResponse({
);
}
- return ;
+ if (bodyUrl.error) {
+ return {String(bodyUrl.error)};
+ }
+
+ if (bodyUrl.data == null) {
+ return Empty response body
;
+ }
+
+ return ;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
diff --git a/apps/yaak-client/components/responseViewers/AudioViewer.tsx b/apps/yaak-client/components/responseViewers/AudioViewer.tsx
index 89f102de..880e7649 100644
--- a/apps/yaak-client/components/responseViewers/AudioViewer.tsx
+++ b/apps/yaak-client/components/responseViewers/AudioViewer.tsx
@@ -1,29 +1,29 @@
import { useEffect, useState } from "react";
-import { platform } from "@yaakapp-internal/platform";
interface Props {
- bodyPath?: string;
+ /** A URL the host resolved, for a body it already stored. */
+ url?: string;
data?: Uint8Array;
mimeType?: string;
}
-export function AudioViewer({ bodyPath, data, mimeType }: Props) {
+export function AudioViewer({ url, data, mimeType }: Props) {
const [src, setSrc] = useState();
useEffect(() => {
- if (bodyPath) {
- setSrc(platform.files.url(bodyPath));
+ if (url) {
+ setSrc(url);
} else if (data) {
// The type matters here in a way it doesn't for an image: a media element goes by what
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
- const url = URL.createObjectURL(blob);
- setSrc(url);
- return () => URL.revokeObjectURL(url);
+ const objectUrl = URL.createObjectURL(blob);
+ setSrc(objectUrl);
+ return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
- }, [bodyPath, data, mimeType]);
+ }, [url, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return ;
diff --git a/apps/yaak-client/components/responseViewers/ImageViewer.tsx b/apps/yaak-client/components/responseViewers/ImageViewer.tsx
index 3b50d4dd..11d19e24 100644
--- a/apps/yaak-client/components/responseViewers/ImageViewer.tsx
+++ b/apps/yaak-client/components/responseViewers/ImageViewer.tsx
@@ -1,10 +1,10 @@
import classNames from "classnames";
import { useEffect, useState } from "react";
-import { platform } from "@yaakapp-internal/platform";
type Props = { className?: string; mimeType?: string } & (
| {
- bodyPath: string;
+ /** A URL the host resolved, for a body it already stored. */
+ url: string;
}
| {
data: ArrayBuffer;
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
export function ImageViewer({ className, mimeType, ...props }: Props) {
const [src, setSrc] = useState();
- const bodyPath = "bodyPath" in props ? props.bodyPath : null;
+ const url = "url" in props ? props.url : null;
const data = "data" in props ? props.data : null;
useEffect(() => {
- if (bodyPath != null) {
- setSrc(platform.files.url(bodyPath));
+ if (url != null) {
+ setSrc(url);
} else if (data != null) {
const blob = new Blob([data], { type: mimeType ?? "image/png" });
- const url = URL.createObjectURL(blob);
- setSrc(url);
- return () => URL.revokeObjectURL(url);
+ const objectUrl = URL.createObjectURL(blob);
+ setSrc(objectUrl);
+ return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
- }, [bodyPath, data, mimeType]);
+ }, [url, data, mimeType]);
return (
{
@@ -18,7 +17,8 @@ fireAndForget(
);
interface Props {
- bodyPath?: string;
+ /** A URL the host resolved, for a body it already stored. */
+ url?: string;
data?: Uint8Array;
}
@@ -27,7 +27,7 @@ const options = {
standardFontDataUrl: "/standard_fonts/",
};
-export function PdfViewer({ bodyPath, data }: Props) {
+export function PdfViewer({ url, data }: Props) {
const containerRef = useRef(null);
const [numPages, setNumPages] = useState();
@@ -36,8 +36,8 @@ export function PdfViewer({ bodyPath, data }: Props) {
// During render, not in an effect: an effect leaves the first paint with no file, and
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => {
- if (bodyPath) {
- return platform.files.url(bodyPath);
+ if (url) {
+ return url;
}
if (data) {
// Create a copy to avoid "Buffer is already detached" errors
@@ -45,7 +45,7 @@ export function PdfViewer({ bodyPath, data }: Props) {
return { data: new Uint8Array(data) };
}
return undefined;
- }, [bodyPath, data]);
+ }, [url, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
diff --git a/apps/yaak-client/components/responseViewers/VideoViewer.tsx b/apps/yaak-client/components/responseViewers/VideoViewer.tsx
index bee85e10..f1a31643 100644
--- a/apps/yaak-client/components/responseViewers/VideoViewer.tsx
+++ b/apps/yaak-client/components/responseViewers/VideoViewer.tsx
@@ -1,28 +1,28 @@
import { useEffect, useState } from "react";
-import { platform } from "@yaakapp-internal/platform";
interface Props {
- bodyPath?: string;
+ /** A URL the host resolved, for a body it already stored. */
+ url?: string;
data?: Uint8Array;
mimeType?: string;
}
-export function VideoViewer({ bodyPath, data, mimeType }: Props) {
+export function VideoViewer({ url, data, mimeType }: Props) {
const [src, setSrc] = useState();
useEffect(() => {
- if (bodyPath) {
- setSrc(platform.files.url(bodyPath));
+ if (url) {
+ setSrc(url);
} else if (data) {
// As in AudioViewer: a media element trusts the declared type instead of sniffing
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
- const url = URL.createObjectURL(blob);
- setSrc(url);
- return () => URL.revokeObjectURL(url);
+ const objectUrl = URL.createObjectURL(blob);
+ setSrc(objectUrl);
+ return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
- }, [bodyPath, data, mimeType]);
+ }, [url, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return ;
diff --git a/apps/yaak-client/hooks/useResponseBodyUrl.ts b/apps/yaak-client/hooks/useResponseBodyUrl.ts
new file mode 100644
index 00000000..59b80cef
--- /dev/null
+++ b/apps/yaak-client/hooks/useResponseBodyUrl.ts
@@ -0,0 +1,23 @@
+import { useQuery } from "@tanstack/react-query";
+import type { HttpResponse } from "@yaakapp-internal/models";
+import { platform } from "@yaakapp-internal/platform";
+
+/**
+ * A URL for a stored response body, for the viewers that hand one to an element
+ * instead of reading the bytes themselves.
+ *
+ * Resolved once here rather than inside each viewer: the host may have to ask
+ * the backend where the body is, and a viewer that computes its source during
+ * render (the PDF one, deliberately) needs it settled before it mounts.
+ *
+ * Null data means the response has no stored body.
+ */
+export function useResponseBodyUrl(response: HttpResponse | null) {
+ const responseId = response?.id ?? null;
+
+ return useQuery({
+ queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
+ enabled: responseId != null,
+ queryFn: () => (responseId == null ? null : platform.files.responseBodyUrl(responseId)),
+ });
+}
diff --git a/apps/yaak-client/lib/responseBody.ts b/apps/yaak-client/lib/responseBody.ts
index caa45bab..b3a2ded1 100644
--- a/apps/yaak-client/lib/responseBody.ts
+++ b/apps/yaak-client/lib/responseBody.ts
@@ -5,6 +5,12 @@ import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-in
import { rpc } from "./rpc";
import { platform } from "@yaakapp-internal/platform";
+/**
+ * Reading a response body means naming the response, never the file it lives
+ * in: the backend resolves an id against its own records, so nothing the UI
+ * says can point a read somewhere else.
+ */
+
export async function getResponseBodyText({
response,
filter,
@@ -13,7 +19,7 @@ export async function getResponseBodyText({
filter: string | null;
}): Promise {
const result = await rpc("cmd_http_response_body", {
- response,
+ responseId: response.id,
filter,
});
@@ -27,10 +33,9 @@ export async function getResponseBodyText({
export async function getResponseBodyEventSource(
response: HttpResponse,
): Promise {
- if (!response.bodyPath) return [];
try {
const events = await rpc("cmd_get_sse_events", {
- filePath: response.bodyPath,
+ responseId: response.id,
});
if (events.length > 0) {
return events;
@@ -39,8 +44,9 @@ export async function getResponseBodyEventSource(
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
}
- const bytes = await platform.files.readFile(response.bodyPath);
- const text = new TextDecoder("utf-8").decode(bytes);
+ const text = await getResponseBodyDecoded(response);
+ if (text == null) return [];
+
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data,
eventType: "",
@@ -53,16 +59,19 @@ export async function getResponseBodySseSummary(
response: HttpResponse,
resultKeyPath: string,
): Promise {
- if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
+ const text = await getResponseBodyDecoded(response);
+ if (text == null) return { fragmentCount: 0, summary: "" };
- const bytes = await platform.files.readFile(response.bodyPath);
- const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath);
}
export async function getResponseBodyBytes(
response: HttpResponse,
): Promise | null> {
- if (!response.bodyPath) return null;
- return platform.files.readFile(response.bodyPath);
+ return platform.files.readResponseBody(response.id);
+}
+
+async function getResponseBodyDecoded(response: HttpResponse): Promise {
+ const bytes = await getResponseBodyBytes(response);
+ return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
}
diff --git a/crates-server/yaak-server/src/rpc/commands.rs b/crates-server/yaak-server/src/rpc/commands.rs
index f0e60a21..618f433a 100644
--- a/crates-server/yaak-server/src/rpc/commands.rs
+++ b/crates-server/yaak-server/src/rpc/commands.rs
@@ -11,10 +11,11 @@ use super::{BridgeCtx, UNSUPPORTED_COMMANDS, unsupported_command};
use mime_guess::{Mime, mime};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::str::FromStr;
use yaak::import::{ImportDataParams, import_data as import_data_shared};
use yaak::models_ops::{delete_model, duplicate_model, upsert_model};
+use yaak::responses::{is_response_id, response_body_path};
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_core::WorkspaceContext;
use yaak_models::models::{
@@ -487,10 +488,51 @@ async fn cmd_send_ephemeral_request(
// -- Reading responses --
+/// Where a response's body is, and what it is meant to be read as.
+struct ResponseBodyLocation {
+ /// None when the response has no stored body.
+ path: Option,
+ /// The response's declared `Content-Type`, empty when it has none. An
+ /// ephemeral response has no headers to consult, so its body decodes as
+ /// UTF-8.
+ content_type: String,
+}
+
+/// Find a response's body from its id alone.
+///
+/// The tab hands back an id and never a path, so nothing a page says can point
+/// this at a file the engine didn't write — the same rule `GET
+/// /responses/:id/body` follows. Persisted responses carry the path they were
+/// written to; ephemeral ones (GraphQL introspection) never reach the database,
+/// but their body still lands at `/`, which is how
+/// [`yaak::send`] builds the path in the first place.
+fn locate_response_body(ctx: &BridgeCtx, response_id: &str) -> Result {
+ if !is_response_id(response_id) {
+ return Err(RpcError { message: format!("Invalid response ID {response_id}") });
+ }
+
+ match ctx.state.db().get_http_response(response_id) {
+ Ok(response) => Ok(ResponseBodyLocation {
+ path: response.body_path.map(PathBuf::from),
+ content_type: response
+ .headers
+ .iter()
+ .find(|h| h.name.eq_ignore_ascii_case("content-type"))
+ .map(|h| h.value.clone())
+ .unwrap_or_default(),
+ }),
+ Err(yaak_models::error::Error::ModelNotFound(_)) => Ok(ResponseBodyLocation {
+ path: Some(response_body_path(&ctx.state.response_dir(), response_id)),
+ content_type: String::new(),
+ }),
+ Err(e) => Err(err(e)),
+ }
+}
+
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdHttpResponseBodyReq {
- pub response: HttpResponse,
+ pub response_id: String,
pub filter: Option,
}
@@ -498,19 +540,12 @@ async fn cmd_http_response_body(
ctx: BridgeCtx,
req: CmdHttpResponseBodyReq,
) -> Result {
- let Some(body_path) = req.response.body_path else {
+ let location = locate_response_body(&ctx, &req.response_id)?;
+ let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
- let content_type = req
- .response
- .headers
- .iter()
- .find_map(|h| {
- if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
- })
- .unwrap_or_default();
-
+ let content_type = location.content_type.as_str();
let body = read_response_body(&body_path, content_type)
.await
.ok_or_else(|| RpcError { message: "Failed to find response body".to_string() })?;
@@ -576,16 +611,20 @@ async fn cmd_get_http_response_events(
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdGetSseEventsReq {
- pub file_path: String,
+ pub response_id: String,
}
async fn cmd_get_sse_events(
- _ctx: BridgeCtx,
+ ctx: BridgeCtx,
req: CmdGetSseEventsReq,
) -> Result> {
use eventsource_client::{EventParser, SSE};
- let body = std::fs::read(&req.file_path).map_err(err)?;
+ let Some(body_path) = locate_response_body(&ctx, &req.response_id)?.path else {
+ return Ok(Vec::new());
+ };
+
+ let body = std::fs::read(&body_path).map_err(err)?;
let mut event_parser = EventParser::new();
event_parser.process_bytes(body).map_err(err)?;
diff --git a/crates-server/yaak-server/src/rpc/mod.rs b/crates-server/yaak-server/src/rpc/mod.rs
index 92c8f0f2..af53ea31 100644
--- a/crates-server/yaak-server/src/rpc/mod.rs
+++ b/crates-server/yaak-server/src/rpc/mod.rs
@@ -118,6 +118,11 @@ pub const UNSUPPORTED_COMMANDS: &[&str] = &[
"cmd_reveal_workspace_key",
"cmd_set_workspace_key",
// Things that need a local filesystem the tab can point at.
+ //
+ // `cmd_http_response_body_path` is how the desktop host turns a response id
+ // into a file it can open; a tab has nothing to do with the answer and
+ // fetches `/responses/:id/body` instead.
+ "cmd_http_response_body_path",
"cmd_export_data",
"cmd_save_response",
"cmd_save_base64_to_binary",
diff --git a/crates-tauri/yaak-app-client/bindings/gen_rpc.ts b/crates-tauri/yaak-app-client/bindings/gen_rpc.ts
index f501c1fd..a0ef4b1b 100644
--- a/crates-tauri/yaak-app-client/bindings/gen_rpc.ts
+++ b/crates-tauri/yaak-app-client/bindings/gen_rpc.ts
@@ -59,7 +59,7 @@ export type CmdGetHttpAuthenticationSummariesReq = Record;
export type CmdGetHttpResponseEventsReq = { responseId: string, };
-export type CmdGetSseEventsReq = { filePath: string, };
+export type CmdGetSseEventsReq = { responseId: string, };
export type CmdGetThemesReq = Record;
@@ -135,7 +135,9 @@ export type CmdHttpRequestActionsReq = Record;
export type CmdHttpRequestBodyReq = { responseId: string, };
-export type CmdHttpResponseBodyReq = { response: HttpResponse, filter: string | null, };
+export type CmdHttpResponseBodyPathReq = { responseId: string, };
+
+export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
export type CmdImportDataReq = { filePath: string, };
@@ -231,4 +233,4 @@ export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
* The wire schema, exported to TypeScript. Field name = command name,
* tuple = (request payload, response payload).
*/
-export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, HttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], };
+export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, HttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], };
diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs
index d7de130e..ddca543f 100644
--- a/crates-tauri/yaak-app-client/src/lib.rs
+++ b/crates-tauri/yaak-app-client/src/lib.rs
@@ -30,6 +30,7 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
+use yaak::responses::{is_response_id, response_body_path};
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -1020,27 +1021,64 @@ async fn cmd_format_graphql(text: &str) -> YaakResult {
}
}
+/// Where a response's body is, and what it is meant to be read as.
+struct ResponseBodyLocation {
+ /// None when the response has no stored body.
+ path: Option,
+ /// The response's declared `Content-Type`, empty when it has none. An
+ /// ephemeral response has no headers to consult, so its body decodes as
+ /// UTF-8.
+ content_type: String,
+}
+
+/// Find a response's body from its id alone.
+///
+/// The frontend hands back an id and never a path, so nothing a caller says can
+/// point this at a file the engine didn't write. Persisted responses carry the
+/// path they were written to; ephemeral ones (GraphQL introspection) never
+/// reach the database, but their body still lands at `/`,
+/// which is how [`yaak::send`] builds the path in the first place.
+fn locate_response_body(
+ app_handle: &AppHandle,
+ response_id: &str,
+) -> YaakResult {
+ if !is_response_id(response_id) {
+ return Err(GenericError(format!("Invalid response ID {response_id}")));
+ }
+
+ match app_handle.db().get_http_response(response_id) {
+ Ok(response) => Ok(ResponseBodyLocation {
+ path: response.body_path.map(PathBuf::from),
+ content_type: response
+ .headers
+ .iter()
+ .find(|h| h.name.eq_ignore_ascii_case("content-type"))
+ .map(|h| h.value.clone())
+ .unwrap_or_default(),
+ }),
+ Err(yaak_models::error::Error::ModelNotFound(_)) => {
+ let response_dir = app_handle.path().app_data_dir()?.join("responses");
+ Ok(ResponseBodyLocation {
+ path: Some(response_body_path(&response_dir, response_id)),
+ content_type: String::new(),
+ })
+ }
+ Err(e) => Err(e.into()),
+ }
+}
+
async fn cmd_http_response_body(
window: WebviewWindow,
plugin_manager: State<'_, PluginManager>,
- response: HttpResponse,
+ response_id: &str,
filter: Option<&str>,
) -> YaakResult {
- let body_path = match response.body_path {
- None => {
- return Ok(FilterResponse { content: String::new(), error: None });
- }
- Some(p) => p,
+ let location = locate_response_body(window.app_handle(), response_id)?;
+ let Some(body_path) = location.path else {
+ return Ok(FilterResponse { content: String::new(), error: None });
};
- let content_type = response
- .headers
- .iter()
- .find_map(|h| {
- if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
- })
- .unwrap_or_default();
-
+ let content_type = location.content_type.as_str();
let body = read_response_body(&body_path, content_type)
.await
.ok_or(GenericError("Failed to find response body".to_string()))?;
@@ -1053,6 +1091,20 @@ async fn cmd_http_response_body(
}
}
+/// The body's path on this machine, for the desktop host to read or hand to the
+/// webview's asset protocol.
+///
+/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
+/// the path, and only because it is about to open the file itself. Hosts
+/// without a filesystem serve the same bytes over HTTP instead.
+async fn cmd_http_response_body_path(
+ app_handle: AppHandle,
+ response_id: &str,
+) -> YaakResult