diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx
index 645de078..8d0697d7 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<{ bodyUrl: 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..f72f9fef 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 for the body the host already stored. */
+ bodyUrl?: string;
data?: Uint8Array;
mimeType?: string;
}
-export function AudioViewer({ bodyPath, data, mimeType }: Props) {
+export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState();
useEffect(() => {
- if (bodyPath) {
- setSrc(platform.files.url(bodyPath));
+ if (bodyUrl) {
+ setSrc(bodyUrl);
} 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]);
+ }, [bodyUrl, 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..75a6e0c1 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 for the body the host already stored. */
+ bodyUrl: 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 bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
const data = "data" in props ? props.data : null;
useEffect(() => {
- if (bodyPath != null) {
- setSrc(platform.files.url(bodyPath));
+ if (bodyUrl != null) {
+ setSrc(bodyUrl);
} 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]);
+ }, [bodyUrl, data, mimeType]);
return (
{
@@ -18,7 +17,8 @@ fireAndForget(
);
interface Props {
- bodyPath?: string;
+ /** A URL for the body the host already stored. */
+ bodyUrl?: string;
data?: Uint8Array;
}
@@ -27,7 +27,7 @@ const options = {
standardFontDataUrl: "/standard_fonts/",
};
-export function PdfViewer({ bodyPath, data }: Props) {
+export function PdfViewer({ bodyUrl, 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 (bodyUrl) {
+ return bodyUrl;
}
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]);
+ }, [bodyUrl, 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..003d1745 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 for the body the host already stored. */
+ bodyUrl?: string;
data?: Uint8Array;
mimeType?: string;
}
-export function VideoViewer({ bodyPath, data, mimeType }: Props) {
+export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState();
useEffect(() => {
- if (bodyPath) {
- setSrc(platform.files.url(bodyPath));
+ if (bodyUrl) {
+ setSrc(bodyUrl);
} 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]);
+ }, [bodyUrl, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return ;
diff --git a/apps/yaak-client/hooks/useIntrospectGraphQL.ts b/apps/yaak-client/hooks/useIntrospectGraphQL.ts
index fd925bba..c316b57f 100644
--- a/apps/yaak-client/hooks/useIntrospectGraphQL.ts
+++ b/apps/yaak-client/hooks/useIntrospectGraphQL.ts
@@ -5,7 +5,6 @@ import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql";
import { useCallback, useEffect, useMemo, useState } from "react";
import { minPromiseMillis } from "../lib/minPromiseMillis";
-import { getResponseBodyText } from "../lib/responseBody";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { useDebouncedValue } from "@yaakapp-internal/ui";
@@ -55,7 +54,7 @@ export function useIntrospectGraphQL(
bodyType: "application/json",
body: { text: introspectionRequestBody },
};
- const response = await minPromiseMillis(
+ const { response, body } = await minPromiseMillis(
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
700,
);
@@ -64,14 +63,16 @@ export function useIntrospectGraphQL(
return setError(response.error);
}
- const bodyText = await getResponseBodyText({ response, filter: null });
+ // The send hands back the only copy of the body — an unsaved response has
+ // nothing on disk and no row to read it back from
+ const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
if (response.status < 200 || response.status >= 300) {
return setError(
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
);
}
- if (bodyText === null) {
+ if (bodyText === "") {
return setError("Empty body returned in response");
}
diff --git a/apps/yaak-client/hooks/useResponseBodyUrl.ts b/apps/yaak-client/hooks/useResponseBodyUrl.ts
new file mode 100644
index 00000000..01e47c7c
--- /dev/null
+++ b/apps/yaak-client/hooks/useResponseBodyUrl.ts
@@ -0,0 +1,24 @@
+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,
+ // A response body is stored under the response's own id
+ queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
+ });
+}
diff --git a/apps/yaak-client/lib/responseBody.ts b/apps/yaak-client/lib/responseBody.ts
index caa45bab..bc0fe23b 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,20 @@ 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);
+ // A response body is stored under the response's own id
+ return platform.blobs.read(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/apps/yaak-client/lib/sendEphemeralRequest.ts b/apps/yaak-client/lib/sendEphemeralRequest.ts
index d50f1926..bd6ca524 100644
--- a/apps/yaak-client/lib/sendEphemeralRequest.ts
+++ b/apps/yaak-client/lib/sendEphemeralRequest.ts
@@ -1,11 +1,12 @@
-import type { HttpRequest, HttpResponse } from "@yaakapp-internal/models";
+import type { HttpRequest } from "@yaakapp-internal/models";
+import type { EphemeralHttpResponse } from "@yaakapp-internal/tauri-client";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { rpc } from "./rpc";
export async function sendEphemeralRequest(
request: HttpRequest,
environmentId: string | null,
-): Promise {
+): Promise {
// Remove some things that we don't want to associate
const newRequest = { ...request };
return rpc("cmd_send_ephemeral_request", {
diff --git a/crates-tauri/yaak-app-client/bindings/gen_rpc.ts b/crates-tauri/yaak-app-client/bindings/gen_rpc.ts
index f501c1fd..299dce27 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, };
@@ -209,6 +211,15 @@ export type CmdWsDeleteConnectionsReq = { requestId: string, };
export type CmdWsSendReq = { connectionId: string, environmentId: string | null, };
+/**
+ * An unsaved response and its body.
+ *
+ * The body rides along because nothing stored it: there is no database row to
+ * look up later and no file for a host to read, so this is the caller's only
+ * copy.
+ */
+export type EphemeralHttpResponse = { response: HttpResponse, body: Array, };
+
export type ModelsDeleteReq = { model: AnyModel, };
export type ModelsDuplicateReq = { modelType: string, modelId: string, };
@@ -231,4 +242,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, EphemeralHttpResponse], 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/http_request.rs b/crates-tauri/yaak-app-client/src/http_request.rs
index 95e35e4d..4bdbff5d 100644
--- a/crates-tauri/yaak-app-client/src/http_request.rs
+++ b/crates-tauri/yaak-app-client/src/http_request.rs
@@ -8,7 +8,7 @@ use std::sync::Arc;
use std::time::Instant;
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::sync::watch::Receiver;
-use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
+use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
@@ -62,6 +62,12 @@ impl ResponseContext {
}
}
+/// What a send produced: the response, and where its body went.
+pub struct SentHttpRequest {
+ pub response: HttpResponse,
+ pub body: ResponseBody,
+}
+
pub async fn send_http_request(
window: &WebviewWindow,
unrendered_request: &HttpRequest,
@@ -69,7 +75,7 @@ pub async fn send_http_request(
environment: Option,
cookie_jar: Option,
cancelled_rx: &mut Receiver,
-) -> Result {
+) -> Result {
send_http_request_with_context(
window,
unrendered_request,
@@ -90,7 +96,7 @@ pub async fn send_http_request_with_context(
cookie_jar: Option,
cancelled_rx: &Receiver,
plugin_context: &PluginContext,
-) -> Result {
+) -> Result {
let app_handle = window.app_handle().clone();
let update_source = UpdateSource::from_window_label(window.label());
let mut response_ctx =
@@ -110,7 +116,7 @@ pub async fn send_http_request_with_context(
.await;
match result {
- Ok(response) => Ok(response),
+ Ok(sent) => Ok(sent),
Err(e) => {
let error = e.to_string();
let elapsed = start.elapsed().as_millis() as i32;
@@ -123,7 +129,12 @@ pub async fn send_http_request_with_context(
}
r.error = Some(error);
});
- Ok(response_ctx.response().clone())
+ // The send failed, so whatever body exists is the partial one
+ // already on disk under the response's id.
+ Ok(SentHttpRequest {
+ response: response_ctx.response().clone(),
+ body: ResponseBody::Stored,
+ })
}
}
}
@@ -136,7 +147,7 @@ async fn send_http_request_inner(
cancelled_rx: &Receiver,
plugin_context: &PluginContext,
response_ctx: &mut ResponseContext,
-) -> Result {
+) -> Result {
let app_handle = window.app_handle().clone();
let plugin_manager = Arc::new((*app_handle.state::()).clone());
let encryption_manager = Arc::new((*app_handle.state::()).clone());
@@ -165,7 +176,7 @@ async fn send_http_request_inner(
.await
.map_err(|e| GenericError(e.to_string()))?;
- Ok(result.response)
+ Ok(SentHttpRequest { response: result.response, body: result.response_body })
}
pub fn resolve_http_request(
diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs
index d7de130e..4d14de63 100644
--- a/crates-tauri/yaak-app-client/src/lib.rs
+++ b/crates-tauri/yaak-app-client/src/lib.rs
@@ -8,6 +8,7 @@ use crate::import::import_data;
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_json_value, render_template};
+use crate::rpc_ext::EphemeralHttpResponse;
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult;
@@ -30,6 +31,7 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
+use yaak::send::ResponseBody;
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -981,13 +983,18 @@ async fn cmd_restart(app_handle: AppHandle) -> YaakResult<()> {
Ok(())
}
+/// Send without saving anything.
+///
+/// The response never reaches the database, so its body cannot be read back by
+/// id later the way a saved response's can. It comes back here instead, which
+/// is the only copy the caller gets.
async fn cmd_send_ephemeral_request(
mut request: HttpRequest,
environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
window: WebviewWindow,
app_handle: AppHandle,
-) -> YaakResult {
+) -> YaakResult {
let response = HttpResponse::default();
request.id = "".to_string();
let environment = match environment_id {
@@ -1006,7 +1013,18 @@ async fn cmd_send_ephemeral_request(
}
});
- send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx).await
+ let sent =
+ send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx)
+ .await?;
+
+ // Blanking the request id above is what makes this send unsaved, so the
+ // engine always hands the body back. Failing loudly beats returning an
+ // empty body that reads as "the server sent nothing".
+ let ResponseBody::Returned(body) = sent.body else {
+ return Err(GenericError("Unsaved response did not return a body".to_string()));
+ };
+
+ Ok(EphemeralHttpResponse { response: sent.response, body })
}
async fn cmd_format_json(text: &str) -> YaakResult {
@@ -1020,27 +1038,49 @@ 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.
+ content_type: String,
+}
+
+/// Find a response's body from its id alone.
+///
+/// The frontend hands back an id and never a path, so the only bodies reachable
+/// here are ones the engine wrote and the database still knows about. A
+/// response that was never saved has no entry, and its body came back from the
+/// send that made it.
+fn locate_response_body(
+ app_handle: &AppHandle,
+ response_id: &str,
+) -> YaakResult {
+ let response = app_handle.db().get_http_response(response_id)?;
+
+ 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(),
+ })
+}
+
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 +1093,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