fix http response load when filter (#251)

This commit is contained in:
Hao Xiang
2025-09-17 04:01:00 +08:00
committed by GitHub
parent 8c3ed60579
commit fec64b5c02
11 changed files with 97 additions and 102 deletions

11
src-tauri/Cargo.lock generated
View File

@@ -822,6 +822,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "charset"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
dependencies = [
"base64 0.22.1",
"encoding_rs",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.41" version = "0.4.41"
@@ -7826,6 +7836,7 @@ dependencies = [
name = "yaak-app" name = "yaak-app"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"charset",
"chrono", "chrono",
"cookie", "cookie",
"encoding_rs", "encoding_rs",

View File

@@ -83,6 +83,7 @@ yaak-sse = { workspace = true }
yaak-sync = { workspace = true } yaak-sync = { workspace = true }
yaak-templates = { workspace = true } yaak-templates = { workspace = true }
yaak-ws = { path = "yaak-ws" } yaak-ws = { path = "yaak-ws" }
charset = "0.1.5"
[workspace.dependencies] [workspace.dependencies]
chrono = "0.4.41" chrono = "0.4.41"

View File

@@ -1,16 +1,27 @@
use encoding_rs::SHIFT_JIS; use log::debug;
use mime_guess::{Mime, mime};
use std::path::Path;
use std::str::FromStr;
use tokio::fs; use tokio::fs;
use yaak_models::models::HttpResponse;
pub async fn read_response_body<'a>( pub async fn read_response_body(body_path: impl AsRef<Path>, content_type: &str) -> Option<String> {
response: HttpResponse, let body = fs::read(body_path).await.ok()?;
) -> Option<String> { let body_charset = parse_charset(content_type).unwrap_or("utf-8".to_string());
let body_path = match response.body_path { debug!("body_charset: {}", body_charset);
None => return None, if let Some(decoder) = charset::Charset::for_label(body_charset.as_bytes()) {
Some(p) => p, debug!("Using decoder for charset: {}", body_charset);
}; let (cow, real_encoding, exist_replace) = decoder.decode(&body);
debug!(
"Decoded body with charset: {}, real_encoding: {:?}, exist_replace: {}",
body_charset, real_encoding, exist_replace
);
return cow.into_owned().into();
}
let body = fs::read(body_path).await.unwrap(); Some(String::from_utf8_lossy(&body).to_string())
let (s, _, _) = SHIFT_JIS.decode(body.as_slice()); }
Some(s.to_string())
fn parse_charset(content_type: &str) -> Option<String> {
let mime: Mime = Mime::from_str(content_type).ok()?;
mime.get_param(mime::CHARSET).map(|v| v.to_string())
} }

View File

@@ -726,33 +726,43 @@ async fn cmd_format_json(text: &str) -> YaakResult<String> {
} }
#[tauri::command] #[tauri::command]
async fn cmd_filter_response<R: Runtime>( async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
response_id: &str,
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
filter: &str, response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> { ) -> YaakResult<FilterResponse> {
let response = app_handle.db().get_http_response(response_id)?; let response = app_handle.db().get_http_response(response_id)?;
if let None = response.body_path { let body_path = match response.body_path {
return Err(GenericError("Response body path not set".to_string())); None => {
} return Err(GenericError("Response body path not set".to_string()));
let mut content_type = "".to_string();
for header in response.headers.iter() {
if header.name.to_lowercase() == "content-type" {
content_type = header.value.to_string().to_lowercase();
break;
} }
} Some(p) => p,
};
let body = read_response_body(response) 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 body = read_response_body(&body_path, content_type)
.await .await
.ok_or(GenericError("Failed to find response body".to_string()))?; .ok_or(GenericError("Failed to find response body".to_string()))?;
// TODO: Have plugins register their own content type (regex?) match filter {
Ok(plugin_manager.filter_data(&window, filter, &body, &content_type).await?) Some(filter) if !filter.is_empty() => {
Ok(plugin_manager.filter_data(&window, filter, &body, content_type).await?)
}
_ => Ok(FilterResponse {
content: body,
error: None,
}),
}
} }
#[tauri::command] #[tauri::command]
@@ -1330,7 +1340,7 @@ pub fn run() {
cmd_delete_send_history, cmd_delete_send_history,
cmd_dismiss_notification, cmd_dismiss_notification,
cmd_export_data, cmd_export_data,
cmd_filter_response, cmd_http_response_body,
cmd_format_json, cmd_format_json,
cmd_get_http_authentication_summaries, cmd_get_http_authentication_summaries,
cmd_get_http_authentication_config, cmd_get_http_authentication_config,

View File

@@ -13,7 +13,7 @@ interface Props {
} }
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) { export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawTextBody = useResponseBodyText(response); const rawTextBody = useResponseBodyText({ responseId: response.id, filter: null });
const contentType = getContentTypeFromHeaders(response.headers); const contentType = getContentTypeFromHeaders(response.headers);
const language = languageFromContentType(contentType, rawTextBody.data ?? ''); const language = languageFromContentType(contentType, rawTextBody.data ?? '');
@@ -24,7 +24,7 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop
if (language === 'html' && pretty) { if (language === 'html' && pretty) {
return <WebPageViewer response={response} />; return <WebPageViewer response={response} />;
} else if (rawTextBody.data == null) { } else if (rawTextBody.data == null) {
return <EmptyStateText>Empty response</EmptyStateText> return <EmptyStateText>Empty response</EmptyStateText>;
} else { } else {
return ( return (
<TextViewer <TextViewer

View File

@@ -3,8 +3,8 @@ import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { createGlobalState } from 'react-use'; import { createGlobalState } from 'react-use';
import { useDebouncedValue } from '../../hooks/useDebouncedValue'; import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useFormatText } from '../../hooks/useFormatText'; import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { EditorProps } from '../core/Editor/Editor'; import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/Editor'; import { Editor } from '../core/Editor/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension'; import { hyperlink } from '../core/Editor/hyperlink/extension';
@@ -36,7 +36,7 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
); );
const isSearching = filterText != null; const isSearching = filterText != null;
const filteredResponse = useFilterResponse({ filter: debouncedFilterText ?? '', responseId }); const filteredResponse = useResponseBodyText({ responseId, filter: debouncedFilterText ?? null });
const toggleSearch = useCallback(() => { const toggleSearch = useCallback(() => {
if (isSearching) { if (isSearching) {
@@ -58,7 +58,7 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
<div key="input" className="w-full !opacity-100"> <div key="input" className="w-full !opacity-100">
<Input <Input
key={requestId} key={requestId}
validate={!(filteredResponse.error || filteredResponse.data?.error)} validate={!filteredResponse.error}
hideLabel hideLabel
autoFocus autoFocus
containerClassName="bg-surface" containerClassName="bg-surface"
@@ -91,7 +91,6 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
}, [ }, [
canFilter, canFilter,
filterText, filterText,
filteredResponse.data?.error,
filteredResponse.error, filteredResponse.error,
filteredResponse.isPending, filteredResponse.isPending,
isSearching, isSearching,
@@ -112,7 +111,7 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
if (filteredResponse.error) { if (filteredResponse.error) {
body = ''; body = '';
} else { } else {
body = filteredResponse.data?.content != null ? filteredResponse.data.content : ''; body = filteredResponse.data != null ? filteredResponse.data : '';
} }
} else { } else {
body = formattedBody; body = formattedBody;

View File

@@ -1,31 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { FilterResponse } from '@yaakapp-internal/plugins';
import { invokeCmd } from '../lib/tauri';
export function useFilterResponse({
responseId,
filter,
}: {
responseId: string | null;
filter: string;
}) {
return useQuery({
queryKey: ['filter_response', responseId, filter],
queryFn: async () => {
if (filter === '') {
return null;
}
const result = (await invokeCmd('cmd_filter_response', {
responseId,
filter,
})) as FilterResponse;
if (result.error) {
console.log("Failed to filter response:", result.error);
}
return result;
},
});
}

View File

@@ -1,12 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp-internal/models';
import { getResponseBodyBlob } from '../lib/responseBody';
export function useResponseBodyBlob(response: HttpResponse) {
return useQuery<Uint8Array | null>({
enabled: response != null,
queryKey: ['response-body-binary', response?.updatedAt],
initialData: null,
queryFn: () => getResponseBodyBlob(response),
}).data;
}

View File

@@ -1,11 +1,15 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp-internal/models';
import { getResponseBodyText } from '../lib/responseBody'; import { getResponseBodyText } from '../lib/responseBody';
export function useResponseBodyText(response: HttpResponse) { export function useResponseBodyText({
responseId,
filter,
}: {
responseId: string;
filter: string | null;
}) {
return useQuery({ return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch queryKey: ['response_body_text', responseId, filter ?? ''],
queryKey: ['response-body-text', response.id, response.updatedAt, response.contentLength], queryFn: () => getResponseBodyText({ responseId, filter }),
queryFn: () => getResponseBodyText(response),
}); });
} }

View File

@@ -1,23 +1,25 @@
import { readFile } from '@tauri-apps/plugin-fs';
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import type { FilterResponse } from '@yaakapp-internal/plugins';
import type { ServerSentEvent } from '@yaakapp-internal/sse'; import type { ServerSentEvent } from '@yaakapp-internal/sse';
import { getCharsetFromContentType } from './model_util';
import { invokeCmd } from './tauri'; import { invokeCmd } from './tauri';
export async function getResponseBodyText(response: HttpResponse): Promise<string | null> { export async function getResponseBodyText({
if (!response.bodyPath) { responseId,
return null; filter,
}: {
responseId: string;
filter: string | null;
}): Promise<string | null> {
const result = await invokeCmd<FilterResponse>('cmd_http_response_body', {
responseId,
filter,
});
if (result.error) {
throw new Error(result.error);
} }
const bytes = await readFile(response.bodyPath); return result.content;
const charset = getCharsetFromContentType(response.headers);
return new TextDecoder(charset ?? 'utf-8', { fatal: false }).decode(bytes);
}
export async function getResponseBodyBlob(response: HttpResponse): Promise<Uint8Array | null> {
if (!response.bodyPath) return null;
return readFile(response.bodyPath);
} }
export async function getResponseBodyEventSource( export async function getResponseBodyEventSource(

View File

@@ -2,30 +2,29 @@ import type { InvokeArgs } from '@tauri-apps/api/core';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
type TauriCmd = type TauriCmd =
| 'cmd_get_themes'
| 'cmd_call_http_authentication_action'
| 'cmd_call_grpc_request_action' | 'cmd_call_grpc_request_action'
| 'cmd_call_http_authentication_action'
| 'cmd_call_http_request_action' | 'cmd_call_http_request_action'
| 'cmd_check_for_updates' | 'cmd_check_for_updates'
| 'cmd_create_grpc_request' | 'cmd_create_grpc_request'
| 'cmd_curl_to_request' | 'cmd_curl_to_request'
| 'cmd_decrypt_template' | 'cmd_decrypt_template'
| 'cmd_secure_template'
| 'cmd_delete_all_grpc_connections' | 'cmd_delete_all_grpc_connections'
| 'cmd_delete_all_http_responses' | 'cmd_delete_all_http_responses'
| 'cmd_delete_send_history' | 'cmd_delete_send_history'
| 'cmd_dismiss_notification' | 'cmd_dismiss_notification'
| 'cmd_export_data' | 'cmd_export_data'
| 'cmd_filter_response'
| 'cmd_format_json' | 'cmd_format_json'
| 'cmd_get_http_authentication_config' | 'cmd_get_http_authentication_config'
| 'cmd_get_http_authentication_summaries' | 'cmd_get_http_authentication_summaries'
| 'cmd_get_sse_events' | 'cmd_get_sse_events'
| 'cmd_get_themes'
| 'cmd_get_workspace_meta' | 'cmd_get_workspace_meta'
| 'cmd_grpc_go' | 'cmd_grpc_go'
| 'cmd_grpc_reflect' | 'cmd_grpc_reflect'
| 'cmd_grpc_request_actions' | 'cmd_grpc_request_actions'
| 'cmd_http_request_actions' | 'cmd_http_request_actions'
| 'cmd_http_response_body'
| 'cmd_import_data' | 'cmd_import_data'
| 'cmd_install_plugin' | 'cmd_install_plugin'
| 'cmd_metadata' | 'cmd_metadata'
@@ -35,6 +34,7 @@ type TauriCmd =
| 'cmd_reload_plugins' | 'cmd_reload_plugins'
| 'cmd_render_template' | 'cmd_render_template'
| 'cmd_save_response' | 'cmd_save_response'
| 'cmd_secure_template'
| 'cmd_send_ephemeral_request' | 'cmd_send_ephemeral_request'
| 'cmd_send_http_request' | 'cmd_send_http_request'
| 'cmd_show_workspace_key' | 'cmd_show_workspace_key'