From 113d0dc3c7d9c99eb4d544ca72c20f010391849b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 6 Dec 2025 06:47:09 -0800 Subject: [PATCH 01/17] Started multi-part response viewer --- package-lock.json | 16 ++++++++ src-web/components/HttpResponsePane.tsx | 3 ++ src-web/components/core/Icon.tsx | 3 ++ .../components/responseViewers/CsvViewer.tsx | 40 +++++++++++------- .../responseViewers/ImageViewer.tsx | 41 ++++++++++++++++--- src-web/hooks/useResponseBodyText.ts | 10 ++++- src-web/lib/responseBody.ts | 8 ++++ src-web/package.json | 1 + 8 files changed, 101 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index dbd9863f..839b4af4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1700,6 +1700,21 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@mjackson/headers": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@mjackson/headers/-/headers-0.11.1.tgz", + "integrity": "sha512-uXXhd4rtDdDwkqAuGef1nuafkCa1NlTmEc1Jzc0NL4YiA1yON1NFXuqJ3hOuKvNKQwkiDwdD+JJlKVyz4dunFA==", + "license": "MIT" + }, + "node_modules/@mjackson/multipart-parser": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@mjackson/multipart-parser/-/multipart-parser-0.10.1.tgz", + "integrity": "sha512-cHMD6+ErH/DrEfC0N6Ru/+1eAdavxdV0C35PzSb5/SD7z3XoaDMc16xPJcb8CahWjSpqHY+Too9sAb6/UNuq7A==", + "license": "MIT", + "dependencies": { + "@mjackson/headers": "^0.11.1" + } + }, "node_modules/@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -18680,6 +18695,7 @@ "@gilbarbara/deep-equal": "^0.3.1", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.3", + "@mjackson/multipart-parser": "^0.10.1", "@prantlf/jsonlint": "^16.0.0", "@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-vim": "^6.3.0", diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index e29bda0e..410ef471 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -30,6 +30,7 @@ import { CsvViewer } from './responseViewers/CsvViewer'; import { EventStreamViewer } from './responseViewers/EventStreamViewer'; import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer'; import { ImageViewer } from './responseViewers/ImageViewer'; +import { MultipartViewer } from './responseViewers/MultipartViewer'; import { SvgViewer } from './responseViewers/SvgViewer'; import { VideoViewer } from './responseViewers/VideoViewer'; @@ -189,6 +190,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ) : mimeType?.match(/^video/i) ? ( + ) : mimeType?.match(/^multipart/i) ? ( + ) : mimeType?.match(/pdf/i) ? ( ) : mimeType?.match(/csv|tab-separated/i) ? ( diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 5d206ada..8d6b6b49 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -53,6 +53,7 @@ import { EyeIcon, EyeOffIcon, FileCodeIcon, + FileIcon, FileTextIcon, FilterIcon, FlameIcon, @@ -180,7 +181,9 @@ const icons = { external_link: ExternalLinkIcon, eye: EyeIcon, eye_closed: EyeOffIcon, + file: FileIcon, file_code: FileCodeIcon, + file_text: FileTextIcon, filter: FilterIcon, flame: FlameIcon, flask: FlaskConicalIcon, diff --git a/src-web/components/responseViewers/CsvViewer.tsx b/src-web/components/responseViewers/CsvViewer.tsx index 9158494e..6664780c 100644 --- a/src-web/components/responseViewers/CsvViewer.tsx +++ b/src-web/components/responseViewers/CsvViewer.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import Papa from 'papaparse'; import { useMemo } from 'react'; import { useResponseBodyText } from '../../hooks/useResponseBodyText'; +import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table'; interface Props { response: HttpResponse; @@ -11,31 +12,42 @@ interface Props { export function CsvViewer({ response, className }: Props) { const body = useResponseBodyText({ response, filter: null }); + return ( +
+ +
+ ); +} +export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) { const parsed = useMemo(() => { - if (body.data == null) return null; - return Papa.parse(body.data); - }, [body]); + if (text == null) return null; + return Papa.parse>(text, { header: true, skipEmptyLines: true }); + }, [text]); if (parsed === null) return null; return (
- - +
+ + + {parsed.meta.fields?.map((field) => ( + {field} + ))} + + + {parsed.data.map((row, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: none - 0 && 'border-b')}> - {row.map((col, j) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: none - + + {parsed.meta.fields?.map((key) => ( + {row[key] ?? ''} ))} - + ))} - -
- {col} -
+ +
); } diff --git a/src-web/components/responseViewers/ImageViewer.tsx b/src-web/components/responseViewers/ImageViewer.tsx index 6967f27e..a584c7c4 100644 --- a/src-web/components/responseViewers/ImageViewer.tsx +++ b/src-web/components/responseViewers/ImageViewer.tsx @@ -1,10 +1,39 @@ import { convertFileSrc } from '@tauri-apps/api/core'; +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; -interface Props { - bodyPath: string; -} +type Props = { className?: string } & ( + | { + bodyPath: string; + } + | { + data: ArrayBuffer; + } +); -export function ImageViewer({ bodyPath }: Props) { - const src = convertFileSrc(bodyPath); - return Response preview; +export function ImageViewer({ className, ...props }: Props) { + const [src, setSrc] = useState(); + const bodyPath = 'bodyPath' in props ? props.bodyPath : null; + const data = 'data' in props ? props.data : null; + + useEffect(() => { + if (bodyPath != null) { + setSrc(convertFileSrc(bodyPath)); + } else if (data != null) { + const blob = new Blob([data], { type: 'image/png' }); + const url = URL.createObjectURL(blob); + setSrc(url); + return () => URL.revokeObjectURL(url); + } else { + setSrc(undefined); + } + }, [bodyPath, data]); + + return ( + Response preview + ); } diff --git a/src-web/hooks/useResponseBodyText.ts b/src-web/hooks/useResponseBodyText.ts index 795ab6e5..68187d42 100644 --- a/src-web/hooks/useResponseBodyText.ts +++ b/src-web/hooks/useResponseBodyText.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import type { HttpResponse } from '@yaakapp-internal/models'; -import { getResponseBodyText } from '../lib/responseBody'; +import { getResponseBodyBytes, getResponseBodyText } from '../lib/responseBody'; export function useResponseBodyText({ response, @@ -21,3 +21,11 @@ export function useResponseBodyText({ queryFn: () => getResponseBodyText({ response, filter }), }); } + +export function useResponseBodyBytes({ response }: { response: HttpResponse }) { + return useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: ['response_body_bytes', response.id, response.updatedAt, response.contentLength], + queryFn: () => getResponseBodyBytes(response), + }); +} diff --git a/src-web/lib/responseBody.ts b/src-web/lib/responseBody.ts index 164f4a6b..40a2a66f 100644 --- a/src-web/lib/responseBody.ts +++ b/src-web/lib/responseBody.ts @@ -1,3 +1,4 @@ +import { readFile } from '@tauri-apps/plugin-fs'; import type { HttpResponse } from '@yaakapp-internal/models'; import type { FilterResponse } from '@yaakapp-internal/plugins'; import type { ServerSentEvent } from '@yaakapp-internal/sse'; @@ -30,3 +31,10 @@ export async function getResponseBodyEventSource( filePath: response.bodyPath, }); } + +export async function getResponseBodyBytes( + response: HttpResponse, +): Promise | null> { + if (!response.bodyPath) return null; + return readFile(response.bodyPath); +} diff --git a/src-web/package.json b/src-web/package.json index 20f8ce78..8d21f01a 100644 --- a/src-web/package.json +++ b/src-web/package.json @@ -20,6 +20,7 @@ "@gilbarbara/deep-equal": "^0.3.1", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.3", + "@mjackson/multipart-parser": "^0.10.1", "@prantlf/jsonlint": "^16.0.0", "@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-vim": "^6.3.0", From 01904cd1c9f1c4b50cd728bcf0f14ca52a9ad2f2 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 6 Dec 2025 06:47:51 -0800 Subject: [PATCH 02/17] Oops, forgot to commit this --- .../responseViewers/MultipartViewer.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src-web/components/responseViewers/MultipartViewer.tsx diff --git a/src-web/components/responseViewers/MultipartViewer.tsx b/src-web/components/responseViewers/MultipartViewer.tsx new file mode 100644 index 00000000..38c873ae --- /dev/null +++ b/src-web/components/responseViewers/MultipartViewer.tsx @@ -0,0 +1,83 @@ +import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser'; +import type { HttpResponse } from '@yaakapp-internal/models'; +import { useState } from 'react'; +import { useResponseBodyBytes } from '../../hooks/useResponseBodyText'; +import { getMimeTypeFromContentType, languageFromContentType } from '../../lib/contentType'; +import { getContentTypeFromHeaders } from '../../lib/model_util'; +import { Editor } from '../core/Editor/LazyEditor'; +import { Icon } from '../core/Icon'; +import { TabContent, Tabs } from '../core/Tabs/Tabs'; +import { CsvViewerInner } from './CsvViewer'; +import { ImageViewer } from './ImageViewer'; + +interface Props { + response: HttpResponse; +} + +export function MultipartViewer({ response }: Props) { + const body = useResponseBodyBytes({ response }); + const [tab, setTab] = useState(); + + if (body.data == null) return null; + + const contentTypeHeader = getContentTypeFromHeaders(response.headers); + const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown'; + + const parsed = parseMultipart(body.data, { boundary }); + const parts = Array.from(parsed); + + return ( + ({ + label: part.name ?? '', + value: part.name ?? '', + rightSlot: + part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? ( +
+ +
+ ) : part.filename ? ( + + ) : null, + }))} + > + {parts.map((part, i) => ( + + + + ))} +
+ ); +} + +function Part({ part }: { part: MultipartPart }) { + const contentType = part.headers.get('content-type'); + const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; + + if (mimeType?.match(/^image/i)) { + return ; + } + + if (mimeType?.match(/csv|tab-separated/i)) { + const content = new TextDecoder().decode(part.arrayBuffer); + return ; + } + + const content = new TextDecoder().decode(part.arrayBuffer); + const language = languageFromContentType(contentType, content); + return ; +} From 46933059f6019bb39d48e71d72fa46dd17a81eec Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 20 Dec 2025 14:10:55 -0800 Subject: [PATCH 03/17] Split up HTTP sending logic (#320) --- .gitignore | 1 + src-tauri/Cargo.lock | 44 +- src-tauri/Cargo.toml | 1 + src-tauri/src/error.rs | 2 +- src-tauri/src/http_request.rs | 883 +++++----------- src-tauri/src/render.rs | 2 +- src-tauri/yaak-common/Cargo.toml | 1 + src-tauri/yaak-common/src/lib.rs | 1 + src-tauri/yaak-common/src/serde.rs | 23 + src-tauri/yaak-http/Cargo.toml | 15 +- src-tauri/yaak-http/src/chained_reader.rs | 78 ++ src-tauri/yaak-http/src/client.rs | 25 +- src-tauri/yaak-http/src/decompress.rs | 188 ++++ src-tauri/yaak-http/src/error.rs | 15 + src-tauri/yaak-http/src/lib.rs | 6 + src-tauri/yaak-http/src/path_placeholders.rs | 6 +- src-tauri/yaak-http/src/proto.rs | 29 + src-tauri/yaak-http/src/sender.rs | 409 ++++++++ src-tauri/yaak-http/src/transaction.rs | 385 +++++++ src-tauri/yaak-http/src/types.rs | 975 ++++++++++++++++++ src-tauri/yaak-http/tests/test.txt | 1 + src-tauri/yaak-models/bindings/gen_models.ts | 2 +- ...251219074602_default-workspace-headers.sql | 15 + ...0251220000000_response-request-headers.sql | 3 + src-tauri/yaak-models/src/error.rs | 2 +- src-tauri/yaak-models/src/models.rs | 11 + src-tauri/yaak-plugins/bindings/gen_models.ts | 2 +- src-tauri/yaak-templates/src/renderer.rs | 6 + src-tauri/yaak-ws/src/commands.rs | 2 +- src-web/components/ExportDataDialog.tsx | 2 +- src-web/components/FolderLayout.tsx | 5 +- src-web/components/HeadersEditor.tsx | 4 +- src-web/components/HttpResponsePane.tsx | 163 +-- src-web/components/ResponseHeaders.tsx | 61 +- .../Settings/SettingsCertificates.tsx | 2 +- src-web/components/core/Button.tsx | 3 +- src-web/components/core/CountBadge.tsx | 13 +- src-web/components/core/DetailsBanner.tsx | 37 +- src-web/components/core/Icon.tsx | 4 + src-web/components/core/SizeTag.tsx | 11 +- src-web/lib/data/encodings.ts | 2 +- 41 files changed, 2708 insertions(+), 732 deletions(-) create mode 100644 src-tauri/yaak-common/src/serde.rs create mode 100644 src-tauri/yaak-http/src/chained_reader.rs create mode 100644 src-tauri/yaak-http/src/decompress.rs create mode 100644 src-tauri/yaak-http/src/proto.rs create mode 100644 src-tauri/yaak-http/src/sender.rs create mode 100644 src-tauri/yaak-http/src/transaction.rs create mode 100644 src-tauri/yaak-http/src/types.rs create mode 100644 src-tauri/yaak-http/tests/test.txt create mode 100644 src-tauri/yaak-models/migrations/20251219074602_default-workspace-headers.sql create mode 100644 src-tauri/yaak-models/migrations/20251220000000_response-request-headers.sql diff --git a/.gitignore b/.gitignore index ee2c7428..a0a25619 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ out .tmp tmp +.zed diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 30ab8329..a91f4c0a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -192,12 +192,14 @@ version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07" dependencies = [ - "brotli", + "brotli 8.0.1", "flate2", "futures-core", "memchr", "pin-project-lite", "tokio", + "zstd", + "zstd-safe", ] [[package]] @@ -536,6 +538,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + [[package]] name = "brotli" version = "8.0.1" @@ -544,7 +557,17 @@ checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -5762,7 +5785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" dependencies = [ "base64 0.22.1", - "brotli", + "brotli 8.0.1", "ico", "json-patch", "plist", @@ -6094,7 +6117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" dependencies = [ "anyhow", - "brotli", + "brotli 8.0.1", "cargo_metadata", "ctor", "dunce", @@ -7903,6 +7926,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-stream", + "tokio-util", "ts-rs", "uuid", "yaak-common", @@ -7929,6 +7953,7 @@ dependencies = [ "regex", "reqwest", "serde", + "serde_json", "tauri", "thiserror 2.0.17", ] @@ -8010,19 +8035,30 @@ dependencies = [ name = "yaak-http" version = "0.1.0" dependencies = [ + "async-compression", + "async-trait", + "brotli 7.0.0", + "bytes", + "flate2", + "futures-util", "hyper-util", "log", + "mime_guess", "regex", "reqwest", "reqwest_cookie_store", "serde", + "serde_json", "tauri", "thiserror 2.0.17", "tokio", + "tokio-util", "tower-service", "urlencoding", + "yaak-common", "yaak-models", "yaak-tls", + "zstd", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index af5ce6c2..19be0850 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,6 +74,7 @@ tauri-plugin-window-state = "2.4.1" thiserror = { workspace = true } tokio = { workspace = true, features = ["sync"] } tokio-stream = "0.1.17" +tokio-util = { version = "0.7", features = ["codec"] } ts-rs = { workspace = true } uuid = "1.12.1" yaak-common = { workspace = true } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 4797fb0d..e0541b51 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -59,7 +59,7 @@ pub enum Error { #[error("Request error: {0}")] RequestError(#[from] reqwest::Error), - #[error("Generic error: {0}")] + #[error("{0}")] GenericError(String), } diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index dfde8946..15cf6041 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -2,32 +2,25 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::render::render_http_request; use crate::response_err; -use http::header::{ACCEPT, USER_AGENT}; -use http::{HeaderMap, HeaderName, HeaderValue}; -use log::{debug, error, warn}; -use mime_guess::Mime; -use reqwest::{Method, Response}; -use reqwest::{Url, multipart}; +use log::debug; use reqwest_cookie_store::{CookieStore, CookieStoreMutex}; -use serde_json::Value; -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::str::FromStr; use std::sync::Arc; -use std::time::Duration; -use tauri::{Manager, Runtime, WebviewWindow}; -use tokio::fs; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; use tokio::fs::{File, create_dir_all}; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; use tokio::sync::watch::Receiver; -use tokio::sync::{Mutex, oneshot}; use yaak_http::client::{ HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth, }; use yaak_http::manager::HttpConnectionManager; +use yaak_http::sender::ReqwestSender; +use yaak_http::transaction::HttpTransaction; +use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params}; use yaak_models::models::{ - Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, - HttpResponseState, ProxySetting, ProxySettingAuth, + CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpResponseState, + ProxySetting, ProxySettingAuth, }; use yaak_models::query_manager::QueryManagerExt; use yaak_models::util::UpdateSource; @@ -36,7 +29,7 @@ use yaak_plugins::events::{ }; use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; -use yaak_templates::{RenderErrorBehavior, RenderOptions}; +use yaak_templates::RenderOptions; use yaak_tls::find_client_certificate; pub async fn send_http_request( @@ -65,62 +58,70 @@ pub async fn send_http_request_with_context( og_response: &HttpResponse, environment: Option, cookie_jar: Option, - cancelled_rx: &mut Receiver, + cancelled_rx: &Receiver, + plugin_context: &PluginContext, +) -> Result { + let app_handle = window.app_handle().clone(); + let response = Arc::new(Mutex::new(og_response.clone())); + let update_source = UpdateSource::from_window(window); + + // Execute the inner send logic and handle errors consistently + let result = send_http_request_inner( + window, + unrendered_request, + og_response, + environment, + cookie_jar, + cancelled_rx, + plugin_context, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(e) => { + Ok(response_err(&app_handle, &*response.lock().await, e.to_string(), &update_source)) + } + } +} + +async fn send_http_request_inner( + window: &WebviewWindow, + unrendered_request: &HttpRequest, + og_response: &HttpResponse, + environment: Option, + cookie_jar: Option, + cancelled_rx: &Receiver, plugin_context: &PluginContext, ) -> Result { let app_handle = window.app_handle().clone(); let plugin_manager = app_handle.state::(); let connection_manager = app_handle.state::(); let settings = window.db().get_settings(); - let workspace = window.db().get_workspace(&unrendered_request.workspace_id)?; - let environment_id = environment.map(|e| e.id); - let environment_chain = window.db().resolve_environments( - &unrendered_request.workspace_id, - unrendered_request.folder_id.as_deref(), - environment_id.as_deref(), - )?; - - let response_id = og_response.id.clone(); + let wrk_id = &unrendered_request.workspace_id; + let fld_id = unrendered_request.folder_id.as_deref(); + let env_id = environment.map(|e| e.id); + let resp_id = og_response.id.clone(); + let workspace = window.db().get_workspace(wrk_id)?; let response = Arc::new(Mutex::new(og_response.clone())); - let update_source = UpdateSource::from_window(window); - - let (resolved_request, auth_context_id) = match resolve_http_request(window, unrendered_request) - { - Ok(r) => r, - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - e.to_string(), - &update_source, - )); - } - }; - + let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?; let cb = PluginTemplateCallback::new(window.app_handle(), &plugin_context, RenderPurpose::Send); + let env_chain = window.db().resolve_environments(&workspace.id, fld_id, env_id.as_deref())?; + let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?; - let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; - - let request = match render_http_request(&resolved_request, environment_chain, &cb, &opt).await { - Ok(r) => r, - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - e.to_string(), - &update_source, - )); - } + // Build the sendable request using the new SendableHttpRequest type + let options = SendableHttpRequestOptions { + follow_redirects: workspace.setting_follow_redirects, + timeout: if workspace.setting_request_timeout > 0 { + Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64)) + } else { + None + }, }; + let mut sendable_request = SendableHttpRequest::from_http_request(&request, options).await?; - let mut url_string = request.url.clone(); - - url_string = ensure_proto(&url_string); - if !url_string.starts_with("http://") && !url_string.starts_with("https://") { - url_string = format!("http://{}", url_string); - } - debug!("Sending request to {} {url_string}", request.method); + debug!("Sending request to {} {}", sendable_request.method, sendable_request.url); let proxy_setting = match settings.proxy { None => HttpConnectionProxySetting::System, @@ -144,7 +145,8 @@ pub async fn send_http_request_with_context( } }; - let client_certificate = find_client_certificate(&url_string, &settings.client_certificates); + let client_certificate = + find_client_certificate(&sendable_request.url, &settings.client_certificates); // Add cookie store if specified let maybe_cookie_manager = match cookie_jar.clone() { @@ -175,523 +177,53 @@ pub async fn send_http_request_with_context( let client = connection_manager .get_client(&HttpConnectionOptions { id: plugin_context.id.clone(), - follow_redirects: workspace.setting_follow_redirects, validate_certificates: workspace.setting_validate_certificates, proxy: proxy_setting, cookie_provider: maybe_cookie_manager.as_ref().map(|(p, _)| Arc::clone(&p)), client_certificate, - timeout: if workspace.setting_request_timeout > 0 { - Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64)) - } else { - None - }, }) .await?; - // Render query parameters - let mut query_params = Vec::new(); - for p in request.url_parameters.clone() { - if !p.enabled || p.name.is_empty() { - continue; - } - query_params.push((p.name, p.value)); + // Apply authentication to the request + apply_authentication( + &window, + &mut sendable_request, + &request, + auth_context_id, + &plugin_manager, + plugin_context, + ) + .await?; + + let start_for_cancellation = Instant::now(); + let final_resp = execute_transaction( + client, + sendable_request, + response.clone(), + &resp_id, + &app_handle, + &update_source, + cancelled_rx.clone(), + ) + .await; + + match final_resp { + Ok(r) => Ok(r), + Err(e) => match app_handle.db().get_http_response(&resp_id) { + Ok(mut r) => { + r.state = HttpResponseState::Closed; + r.elapsed = start_for_cancellation.elapsed().as_millis() as i32; + r.elapsed_headers = start_for_cancellation.elapsed().as_millis() as i32; + r.error = Some(e.to_string()); + app_handle + .db() + .update_http_response_if_id(&r, &UpdateSource::from_window(window)) + .expect("Failed to update response"); + Ok(r) + } + _ => Err(GenericError("Ephemeral request was cancelled".to_string())), + }, } - - let url = match Url::from_str(&url_string) { - Ok(u) => u, - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), - &update_source, - )); - } - }; - - let m = Method::from_str(&request.method.to_uppercase()) - .map_err(|e| GenericError(e.to_string()))?; - let mut request_builder = client.request(m, url).query(&query_params); - - let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, HeaderValue::from_static("yaak")); - headers.insert(ACCEPT, HeaderValue::from_static("*/*")); - - // TODO: Set cookie header ourselves once we also handle redirects. We need to do this - // because reqwest doesn't give us a way to inspect the headers it sent (we have to do - // everything manually to know that). - // if let Some(cookie_store) = maybe_cookie_store.clone() { - // let values1 = cookie_store.get_request_values(&url); - // let raw_value = cookie_store.get_request_values(&url) - // .map(|(name, value)| format!("{}={}", name, value)) - // .collect::>() - // .join("; "); - // headers.insert( - // COOKIE, - // HeaderValue::from_str(&raw_value).expect("Failed to create cookie header"), - // ); - // } - - for h in request.headers.clone() { - if h.name.is_empty() && h.value.is_empty() { - continue; - } - - if !h.enabled { - continue; - } - - let header_name = match HeaderName::from_str(&h.name) { - Ok(n) => n, - Err(e) => { - error!("Failed to create header name: {}", e); - continue; - } - }; - let header_value = match HeaderValue::from_str(&h.value) { - Ok(n) => n, - Err(e) => { - error!("Failed to create header value: {}", e); - continue; - } - }; - - headers.insert(header_name, header_value); - } - - let request_body = request.body.clone(); - if let Some(body_type) = &request.body_type.clone() { - if body_type == "graphql" { - let query = get_str_h(&request_body, "query"); - let variables = get_str_h(&request_body, "variables"); - if request.method.to_lowercase() == "get" { - request_builder = request_builder.query(&[("query", query)]); - if !variables.trim().is_empty() { - request_builder = request_builder.query(&[("variables", variables)]); - } - } else { - let body = if variables.trim().is_empty() { - format!(r#"{{"query":{}}}"#, serde_json::to_string(query).unwrap_or_default()) - } else { - format!( - r#"{{"query":{},"variables":{variables}}}"#, - serde_json::to_string(query).unwrap_or_default() - ) - }; - request_builder = request_builder.body(body.to_owned()); - } - } else if body_type == "application/x-www-form-urlencoded" - && request_body.contains_key("form") - { - let mut form_params = Vec::new(); - let form = request_body.get("form"); - if let Some(f) = form { - match f.as_array() { - None => {} - Some(a) => { - for p in a { - let enabled = get_bool(p, "enabled", true); - let name = get_str(p, "name"); - if !enabled || name.is_empty() { - continue; - } - let value = get_str(p, "value"); - form_params.push((name, value)); - } - } - } - } - request_builder = request_builder.form(&form_params); - } else if body_type == "binary" && request_body.contains_key("filePath") { - let file_path = request_body - .get("filePath") - .ok_or(GenericError("filePath not set".to_string()))? - .as_str() - .unwrap_or_default(); - - match fs::read(file_path).await.map_err(|e| e.to_string()) { - Ok(f) => { - request_builder = request_builder.body(f); - } - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - e, - &update_source, - )); - } - } - } else if body_type == "multipart/form-data" && request_body.contains_key("form") { - let mut multipart_form = multipart::Form::new(); - if let Some(form_definition) = request_body.get("form") { - match form_definition.as_array() { - None => {} - Some(fd) => { - for p in fd { - let enabled = get_bool(p, "enabled", true); - let name = get_str(p, "name").to_string(); - - if !enabled || name.is_empty() { - continue; - } - - let file_path = get_str(p, "file").to_owned(); - let value = get_str(p, "value").to_owned(); - - let mut part = if file_path.is_empty() { - multipart::Part::text(value.clone()) - } else { - match fs::read(file_path.clone()).await { - Ok(f) => multipart::Part::bytes(f), - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - e.to_string(), - &update_source, - )); - } - } - }; - - let content_type = get_str(p, "contentType"); - - // Set or guess mimetype - if !content_type.is_empty() { - part = match part.mime_str(content_type) { - Ok(p) => p, - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - format!("Invalid mime for multi-part entry {e:?}"), - &update_source, - )); - } - }; - } else if !file_path.is_empty() { - let default_mime = - Mime::from_str("application/octet-stream").unwrap(); - let mime = - mime_guess::from_path(file_path.clone()).first_or(default_mime); - part = match part.mime_str(mime.essence_str()) { - Ok(p) => p, - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - format!("Invalid mime for multi-part entry {e:?}"), - &update_source, - )); - } - }; - } - - // Set a file path if it is not empty - if !file_path.is_empty() { - let user_filename = get_str(p, "filename").to_owned(); - let filename = if user_filename.is_empty() { - PathBuf::from(file_path) - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - } else { - user_filename - }; - part = part.file_name(filename); - } - - multipart_form = multipart_form.part(name, part); - } - } - } - } - headers.remove("Content-Type"); // reqwest will add this automatically - request_builder = request_builder.multipart(multipart_form); - } else if request_body.contains_key("text") { - let body = get_str_h(&request_body, "text"); - request_builder = request_builder.body(body.to_owned()); - } else { - warn!("Unsupported body type: {}", body_type); - } - } else { - // No body set - let method = request.method.to_ascii_lowercase(); - let is_body_method = method == "post" || method == "put" || method == "patch"; - // Add Content-Length for methods that commonly accept a body because some servers - // will error if they don't receive it. - if is_body_method && !headers.contains_key("content-length") { - headers.insert("Content-Length", HeaderValue::from_static("0")); - } - } - - // Add headers last, because previous steps may modify them - request_builder = request_builder.headers(headers); - - let mut sendable_req = match request_builder.build() { - Ok(r) => r, - Err(e) => { - warn!("Failed to build request builder {e:?}"); - return Ok(response_err( - &app_handle, - &*response.lock().await, - e.to_string(), - &update_source, - )); - } - }; - - match request.authentication_type { - None => { - // No authentication found. Not even inherited - } - Some(authentication_type) if authentication_type == "none" => { - // Explicitly no authentication - } - Some(authentication_type) => { - let req = CallHttpAuthenticationRequest { - context_id: format!("{:x}", md5::compute(auth_context_id)), - values: serde_json::from_value(serde_json::to_value(&request.authentication)?)?, - url: sendable_req.url().to_string(), - method: sendable_req.method().to_string(), - headers: sendable_req - .headers() - .iter() - .map(|(name, value)| HttpHeader { - name: name.to_string(), - value: value.to_str().unwrap_or_default().to_string(), - }) - .collect(), - }; - let auth_result = plugin_manager - .call_http_authentication(&window, &authentication_type, req, plugin_context) - .await; - let plugin_result = match auth_result { - Ok(r) => r, - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - e.to_string(), - &update_source, - )); - } - }; - - let headers = sendable_req.headers_mut(); - for header in plugin_result.set_headers.unwrap_or_default() { - match (HeaderName::from_str(&header.name), HeaderValue::from_str(&header.value)) { - (Ok(name), Ok(value)) => { - headers.insert(name, value); - } - _ => continue, - }; - } - - if let Some(params) = plugin_result.set_query_parameters { - let mut query_pairs = sendable_req.url_mut().query_pairs_mut(); - for p in params { - query_pairs.append_pair(&p.name, &p.value); - } - } - } - } - - let (resp_tx, resp_rx) = oneshot::channel::>(); - let (done_tx, done_rx) = oneshot::channel::(); - - let start = std::time::Instant::now(); - - tokio::spawn(async move { - let _ = resp_tx.send(client.execute(sendable_req).await); - }); - - let raw_response = tokio::select! { - Ok(r) = resp_rx => r, - _ = cancelled_rx.changed() => { - let mut r = response.lock().await; - r.elapsed_headers = start.elapsed().as_millis() as i32; - r.elapsed = start.elapsed().as_millis() as i32; - return Ok(response_err(&app_handle, &r, "Request was cancelled".to_string(), &update_source)); - } - }; - - { - let app_handle = app_handle.clone(); - let window = window.clone(); - let cancelled_rx = cancelled_rx.clone(); - let response_id = response_id.clone(); - let response = response.clone(); - let update_source = update_source.clone(); - tokio::spawn(async move { - match raw_response { - Ok(mut v) => { - let content_length = v.content_length(); - let response_headers = v.headers().clone(); - let dir = app_handle.path().app_data_dir().unwrap(); - let base_dir = dir.join("responses"); - create_dir_all(base_dir.clone()).await.expect("Failed to create responses dir"); - let body_path = if response_id.is_empty() { - base_dir.join(uuid::Uuid::new_v4().to_string()) - } else { - base_dir.join(response_id.clone()) - }; - - { - let mut r = response.lock().await; - r.body_path = Some(body_path.to_str().unwrap().to_string()); - r.elapsed_headers = start.elapsed().as_millis() as i32; - r.elapsed = start.elapsed().as_millis() as i32; - r.status = v.status().as_u16() as i32; - r.status_reason = v.status().canonical_reason().map(|s| s.to_string()); - r.headers = response_headers - .iter() - .map(|(k, v)| HttpResponseHeader { - name: k.as_str().to_string(), - value: v.to_str().unwrap_or_default().to_string(), - }) - .collect(); - r.url = v.url().to_string(); - r.remote_addr = v.remote_addr().map(|a| a.to_string()); - r.version = match v.version() { - reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()), - reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()), - reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()), - reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()), - reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()), - _ => None, - }; - - r.state = HttpResponseState::Connected; - app_handle - .db() - .update_http_response_if_id(&r, &update_source) - .expect("Failed to update response after connected"); - } - - // Write body to FS - let mut f = File::options() - .create(true) - .truncate(true) - .write(true) - .open(&body_path) - .await - .expect("Failed to open file"); - - let mut written_bytes: usize = 0; - loop { - let chunk = v.chunk().await; - if *cancelled_rx.borrow() { - // Request was canceled - return; - } - match chunk { - Ok(Some(bytes)) => { - let mut r = response.lock().await; - r.elapsed = start.elapsed().as_millis() as i32; - f.write_all(&bytes).await.expect("Failed to write to file"); - f.flush().await.expect("Failed to flush file"); - written_bytes += bytes.len(); - r.content_length = Some(written_bytes as i32); - app_handle - .db() - .update_http_response_if_id(&r, &update_source) - .expect("Failed to update response"); - } - Ok(None) => { - break; - } - Err(e) => { - response_err( - &app_handle, - &*response.lock().await, - e.to_string(), - &update_source, - ); - break; - } - } - } - - // Set the final content length - { - let mut r = response.lock().await; - r.content_length = match content_length { - Some(l) => Some(l as i32), - None => Some(written_bytes as i32), - }; - r.state = HttpResponseState::Closed; - app_handle - .db() - .update_http_response_if_id(&r, &UpdateSource::from_window(&window)) - .expect("Failed to update response"); - }; - - // Add cookie store if specified - if let Some((cookie_store, mut cookie_jar)) = maybe_cookie_manager { - // let cookies = response_headers.get_all(SET_COOKIE).iter().map(|h| { - // println!("RESPONSE COOKIE: {}", h.to_str().unwrap()); - // cookie_store::RawCookie::from_str(h.to_str().unwrap()) - // .expect("Failed to parse cookie") - // }); - // store.store_response_cookies(cookies, &url); - - let json_cookies: Vec = cookie_store - .lock() - .unwrap() - .iter_any() - .map(|c| { - let json_cookie = - serde_json::to_value(&c).expect("Failed to serialize cookie"); - serde_json::from_value(json_cookie) - .expect("Failed to deserialize cookie") - }) - .collect::>(); - cookie_jar.cookies = json_cookies; - if let Err(e) = app_handle - .db() - .upsert_cookie_jar(&cookie_jar, &UpdateSource::from_window(&window)) - { - error!("Failed to update cookie jar: {}", e); - }; - } - } - Err(e) => { - warn!("Failed to execute request {e}"); - response_err( - &app_handle, - &*response.lock().await, - format!("{e} → {e:?}"), - &update_source, - ); - } - }; - - let r = response.lock().await.clone(); - done_tx.send(r).unwrap(); - }); - }; - - let app_handle = app_handle.clone(); - Ok(tokio::select! { - Ok(r) = done_rx => r, - _ = cancelled_rx.changed() => { - match app_handle.with_db(|c| c.get_http_response(&response_id)) { - Ok(mut r) => { - r.state = HttpResponseState::Closed; - r.elapsed = start.elapsed().as_millis() as i32; - r.elapsed_headers = start.elapsed().as_millis() as i32; - app_handle.db().update_http_response_if_id(&r, &UpdateSource::from_window(window)) - .expect("Failed to update response") - }, - _ => { - response_err(&app_handle, &*response.lock().await, "Ephemeral request was cancelled".to_string(), &update_source) - }.clone(), - } - } - }) } pub fn resolve_http_request( @@ -711,46 +243,191 @@ pub fn resolve_http_request( Ok((new_request, authentication_context_id)) } -fn ensure_proto(url_str: &str) -> String { - if url_str.starts_with("http://") || url_str.starts_with("https://") { - return url_str.to_string(); +async fn execute_transaction( + client: reqwest::Client, + sendable_request: SendableHttpRequest, + response: Arc>, + response_id: &String, + app_handle: &AppHandle, + update_source: &UpdateSource, + cancelled_rx: Receiver, +) -> Result { + let sender = ReqwestSender::with_client(client); + let transaction = HttpTransaction::new(sender); + let start = Instant::now(); + + // Capture request headers before sending (headers will be moved) + let request_headers: Vec = sendable_request + .headers + .iter() + .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) + .collect(); + + // Execute the transaction with cancellation support + // This returns the response with headers, but body is not yet consumed + let (http_response, _events) = + transaction.execute_with_cancellation(sendable_request, cancelled_rx.clone()).await?; + + // Prepare the response path before consuming the body + let dir = app_handle.path().app_data_dir()?; + let base_dir = dir.join("responses"); + create_dir_all(&base_dir).await?; + + let body_path = if response_id.is_empty() { + base_dir.join(uuid::Uuid::new_v4().to_string()) + } else { + base_dir.join(&response_id) + }; + + // Extract metadata before consuming the body (headers are available immediately) + let status = http_response.status; + let status_reason = http_response.status_reason.clone(); + let url = http_response.url.clone(); + let remote_addr = http_response.remote_addr.clone(); + let version = http_response.version.clone(); + let content_length = http_response.content_length; + let headers: Vec = http_response + .headers + .iter() + .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) + .collect(); + let headers_timing = http_response.timing.headers; + + // Update response with headers info and mark as connected + { + let mut r = response.lock().await; + r.body_path = Some( + body_path + .to_str() + .ok_or(GenericError(format!("Invalid path {body_path:?}")))? + .to_string(), + ); + r.elapsed_headers = headers_timing.as_millis() as i32; + r.elapsed = start.elapsed().as_millis() as i32; + r.status = status as i32; + r.status_reason = status_reason.clone(); + r.url = url.clone(); + r.remote_addr = remote_addr.clone(); + r.version = version.clone(); + r.headers = headers.clone(); + r.request_headers = request_headers.clone(); + r.state = HttpResponseState::Connected; + app_handle.db().update_http_response_if_id(&r, &update_source)?; } - // Url::from_str will fail without a proto, so add one - let parseable_url = format!("http://{}", url_str); - if let Ok(u) = Url::from_str(parseable_url.as_str()) { - match u.host() { - Some(host) => { - let h = host.to_string(); - // These TLDs force HTTPS - if h.ends_with(".app") || h.ends_with(".dev") || h.ends_with(".page") { - return format!("https://{url_str}"); - } + // Get the body stream for manual consumption + let mut body_stream = http_response.into_body_stream()?; + + // Open file for writing + let mut file = File::options() + .create(true) + .truncate(true) + .write(true) + .open(&body_path) + .await + .map_err(|e| GenericError(format!("Failed to open file: {}", e)))?; + + // Stream body to file, updating DB on each chunk + let mut written_bytes: usize = 0; + let mut buf = [0u8; 8192]; + + loop { + // Check for cancellation - if we already have headers/body, just close cleanly + if *cancelled_rx.borrow() { + break; + } + + match body_stream.read(&mut buf).await { + Ok(0) => break, // EOF + Ok(n) => { + file.write_all(&buf[..n]) + .await + .map_err(|e| GenericError(format!("Failed to write to file: {}", e)))?; + file.flush() + .await + .map_err(|e| GenericError(format!("Failed to flush file: {}", e)))?; + written_bytes += n; + + // Update response in DB with progress + let mut r = response.lock().await; + r.elapsed = start.elapsed().as_millis() as i32; + r.content_length = Some(written_bytes as i32); + app_handle.db().update_http_response_if_id(&r, &update_source)?; + } + Err(e) => { + return Err(GenericError(format!("Failed to read response body: {}", e))); } - None => {} } } - format!("http://{url_str}") + // Final update with closed state + let mut resp = response.lock().await.clone(); + resp.headers = headers; + resp.request_headers = request_headers; + resp.status = status as i32; + resp.status_reason = status_reason; + resp.url = url; + resp.remote_addr = remote_addr; + resp.version = version; + resp.state = HttpResponseState::Closed; + resp.content_length = match content_length { + Some(l) => Some(l as i32), + None => Some(written_bytes as i32), + }; + resp.elapsed = start.elapsed().as_millis() as i32; + resp.elapsed_headers = headers_timing.as_millis() as i32; + resp.body_path = Some( + body_path.to_str().ok_or(GenericError(format!("Invalid path {body_path:?}",)))?.to_string(), + ); + + app_handle.db().update_http_response_if_id(&resp, &update_source)?; + + Ok(resp) } -fn get_bool(v: &Value, key: &str, fallback: bool) -> bool { - match v.get(key) { - None => fallback, - Some(v) => v.as_bool().unwrap_or(fallback), - } -} +async fn apply_authentication( + window: &WebviewWindow, + sendable_request: &mut SendableHttpRequest, + request: &HttpRequest, + auth_context_id: String, + plugin_manager: &PluginManager, + plugin_context: &PluginContext, +) -> Result<()> { + match &request.authentication_type { + None => { + // No authentication found. Not even inherited + } + Some(authentication_type) if authentication_type == "none" => { + // Explicitly no authentication + } + Some(authentication_type) => { + let req = CallHttpAuthenticationRequest { + context_id: format!("{:x}", md5::compute(auth_context_id)), + values: serde_json::from_value(serde_json::to_value(&request.authentication)?)?, + url: sendable_request.url.clone(), + method: sendable_request.method.clone(), + headers: sendable_request + .headers + .iter() + .map(|(name, value)| HttpHeader { + name: name.to_string(), + value: value.to_string(), + }) + .collect(), + }; + let plugin_result = plugin_manager + .call_http_authentication(&window, &authentication_type, req, plugin_context) + .await?; -fn get_str<'a>(v: &'a Value, key: &str) -> &'a str { - match v.get(key) { - None => "", - Some(v) => v.as_str().unwrap_or_default(), - } -} + for header in plugin_result.set_headers.unwrap_or_default() { + sendable_request.insert_header((header.name, header.value)); + } -fn get_str_h<'a>(v: &'a BTreeMap, key: &str) -> &'a str { - match v.get(key) { - None => "", - Some(v) => v.as_str().unwrap_or_default(), + if let Some(params) = plugin_result.set_query_parameters { + let params = params.into_iter().map(|p| (p.name, p.value)).collect::>(); + sendable_request.url = append_query_params(&sendable_request.url, params); + } + } } + Ok(()) } diff --git a/src-tauri/src/render.rs b/src-tauri/src/render.rs index f915bda9..f5ad8fad 100644 --- a/src-tauri/src/render.rs +++ b/src-tauri/src/render.rs @@ -157,7 +157,7 @@ pub async fn render_http_request( let url = parse_and_render(r.url.clone().as_str(), vars, cb, &opt).await?; // This doesn't fit perfectly with the concept of "rendering" but it kind of does - let (url, url_parameters) = apply_path_placeholders(&url, url_parameters); + let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters); Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() }) } diff --git a/src-tauri/yaak-common/Cargo.toml b/src-tauri/yaak-common/Cargo.toml index be033751..3e872f12 100644 --- a/src-tauri/yaak-common/Cargo.toml +++ b/src-tauri/yaak-common/Cargo.toml @@ -10,3 +10,4 @@ reqwest = { workspace = true, features = ["system-proxy", "gzip"] } thiserror = { workspace = true } regex = "1.11.0" serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/src-tauri/yaak-common/src/lib.rs b/src-tauri/yaak-common/src/lib.rs index d315fede..cac9bcc6 100644 --- a/src-tauri/yaak-common/src/lib.rs +++ b/src-tauri/yaak-common/src/lib.rs @@ -1,4 +1,5 @@ pub mod api_client; pub mod error; pub mod platform; +pub mod serde; pub mod window; diff --git a/src-tauri/yaak-common/src/serde.rs b/src-tauri/yaak-common/src/serde.rs new file mode 100644 index 00000000..683cc25d --- /dev/null +++ b/src-tauri/yaak-common/src/serde.rs @@ -0,0 +1,23 @@ +use serde_json::Value; +use std::collections::BTreeMap; + +pub fn get_bool(v: &Value, key: &str, fallback: bool) -> bool { + match v.get(key) { + None => fallback, + Some(v) => v.as_bool().unwrap_or(fallback), + } +} + +pub fn get_str<'a>(v: &'a Value, key: &str) -> &'a str { + match v.get(key) { + None => "", + Some(v) => v.as_str().unwrap_or_default(), + } +} + +pub fn get_str_map<'a>(v: &'a BTreeMap, key: &str) -> &'a str { + match v.get(key) { + None => "", + Some(v) => v.as_str().unwrap_or_default(), + } +} diff --git a/src-tauri/yaak-http/Cargo.toml b/src-tauri/yaak-http/Cargo.toml index c51ff89f..ec7367a5 100644 --- a/src-tauri/yaak-http/Cargo.toml +++ b/src-tauri/yaak-http/Cargo.toml @@ -5,16 +5,27 @@ edition = "2024" publish = false [dependencies] +async-compression = { version = "0.4", features = ["tokio", "gzip", "deflate", "brotli", "zstd"] } +async-trait = "0.1" +brotli = "7" +bytes = "1.5.0" +flate2 = "1" +futures-util = "0.3" +zstd = "0.13" hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] } log = { workspace = true } +mime_guess = "2.0.5" regex = "1.11.1" -reqwest = { workspace = true, features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json", "rustls-tls-manual-roots-no-provider", "socks", "http2"] } +reqwest = { workspace = true, features = ["cookies", "rustls-tls-manual-roots-no-provider", "socks", "http2", "stream"] } reqwest_cookie_store = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } tauri = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "fs", "io-util"] } +tokio-util = { version = "0.7", features = ["codec", "io", "io-util"] } tower-service = "0.3.3" urlencoding = "2.1.3" +yaak-common = { workspace = true } yaak-models = { workspace = true } yaak-tls = { workspace = true } diff --git a/src-tauri/yaak-http/src/chained_reader.rs b/src-tauri/yaak-http/src/chained_reader.rs new file mode 100644 index 00000000..7b5f10a3 --- /dev/null +++ b/src-tauri/yaak-http/src/chained_reader.rs @@ -0,0 +1,78 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, ReadBuf}; + +/// A stream that chains multiple AsyncRead sources together +pub(crate) struct ChainedReader { + readers: Vec, + current_index: usize, + current_reader: Option>, +} + +#[derive(Clone)] +pub(crate) enum ReaderType { + Bytes(Vec), + FilePath(String), +} + +impl ChainedReader { + pub(crate) fn new(readers: Vec) -> Self { + Self { readers, current_index: 0, current_reader: None } + } +} + +impl AsyncRead for ChainedReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + // Try to read from current reader if we have one + if let Some(ref mut reader) = self.current_reader { + let before_len = buf.filled().len(); + return match Pin::new(reader).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + if buf.filled().len() == before_len && buf.remaining() > 0 { + // Current reader is exhausted, move to next + self.current_reader = None; + continue; + } + Poll::Ready(Ok(())) + } + Poll::Ready(Err(e)) => Poll::Ready(Err(e)), + Poll::Pending => Poll::Pending, + }; + } + + // We need to get the next reader + if self.current_index >= self.readers.len() { + // No more readers + return Poll::Ready(Ok(())); + } + + // Get the next reader + let reader_type = self.readers[self.current_index].clone(); + self.current_index += 1; + + match reader_type { + ReaderType::Bytes(bytes) => { + self.current_reader = Some(Box::new(io::Cursor::new(bytes))); + } + ReaderType::FilePath(path) => { + // We need to handle file opening synchronously in poll_read + // This is a limitation - we'll use blocking file open + match std::fs::File::open(&path) { + Ok(file) => { + // Convert std File to tokio File + let tokio_file = tokio::fs::File::from_std(file); + self.current_reader = Some(Box::new(tokio_file)); + } + Err(e) => return Poll::Ready(Err(e)), + } + } + } + } + } +} diff --git a/src-tauri/yaak-http/src/client.rs b/src-tauri/yaak-http/src/client.rs index f8d06acc..f43ebaaf 100644 --- a/src-tauri/yaak-http/src/client.rs +++ b/src-tauri/yaak-http/src/client.rs @@ -1,11 +1,9 @@ use crate::dns::LocalhostResolver; use crate::error::Result; use log::{debug, info, warn}; -use reqwest::redirect::Policy; -use reqwest::{Client, Proxy}; +use reqwest::{Client, Proxy, redirect}; use reqwest_cookie_store::CookieStoreMutex; use std::sync::Arc; -use std::time::Duration; use yaak_tls::{ClientCertificateConfig, get_tls_config}; #[derive(Clone)] @@ -29,11 +27,9 @@ pub enum HttpConnectionProxySetting { #[derive(Clone)] pub struct HttpConnectionOptions { pub id: String, - pub follow_redirects: bool, pub validate_certificates: bool, pub proxy: HttpConnectionProxySetting, pub cookie_provider: Option>, - pub timeout: Option, pub client_certificate: Option, } @@ -41,9 +37,11 @@ impl HttpConnectionOptions { pub(crate) fn build_client(&self) -> Result { let mut client = Client::builder() .connection_verbose(true) - .gzip(true) - .brotli(true) - .deflate(true) + .redirect(redirect::Policy::none()) + // Decompression is handled by HttpTransaction, not reqwest + .no_gzip() + .no_brotli() + .no_deflate() .referer(false) .tls_info(true); @@ -55,12 +53,6 @@ impl HttpConnectionOptions { // Configure DNS resolver client = client.dns_resolver(LocalhostResolver::new()); - // Configure redirects - client = client.redirect(match self.follow_redirects { - true => Policy::limited(10), // TODO: Handle redirects natively - false => Policy::none(), - }); - // Configure cookie provider if let Some(p) = &self.cookie_provider { client = client.cookie_provider(Arc::clone(&p)); @@ -79,11 +71,6 @@ impl HttpConnectionOptions { } } - // Configure timeout - if let Some(d) = self.timeout { - client = client.timeout(d); - } - info!( "Building new HTTP client validate_certificates={} client_cert={}", self.validate_certificates, diff --git a/src-tauri/yaak-http/src/decompress.rs b/src-tauri/yaak-http/src/decompress.rs new file mode 100644 index 00000000..e3764ea6 --- /dev/null +++ b/src-tauri/yaak-http/src/decompress.rs @@ -0,0 +1,188 @@ +use crate::error::{Error, Result}; +use async_compression::tokio::bufread::{ + BrotliDecoder, DeflateDecoder as AsyncDeflateDecoder, GzipDecoder, + ZstdDecoder as AsyncZstdDecoder, +}; +use flate2::read::{DeflateDecoder, GzDecoder}; +use std::io::Read; +use tokio::io::{AsyncBufRead, AsyncRead}; + +/// Supported compression encodings +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentEncoding { + Gzip, + Deflate, + Brotli, + Zstd, + Identity, +} + +impl ContentEncoding { + /// Parse a Content-Encoding header value into an encoding type. + /// Returns Identity for unknown or missing encodings. + pub fn from_header(value: Option<&str>) -> Self { + match value.map(|s| s.trim().to_lowercase()).as_deref() { + Some("gzip") | Some("x-gzip") => ContentEncoding::Gzip, + Some("deflate") => ContentEncoding::Deflate, + Some("br") => ContentEncoding::Brotli, + Some("zstd") => ContentEncoding::Zstd, + _ => ContentEncoding::Identity, + } + } +} + +/// Result of decompression, containing both the decompressed data and size info +#[derive(Debug)] +pub struct DecompressResult { + pub data: Vec, + pub compressed_size: u64, + pub decompressed_size: u64, +} + +/// Decompress data based on the Content-Encoding. +/// Returns the original data unchanged if encoding is Identity or unknown. +pub fn decompress(data: Vec, encoding: ContentEncoding) -> Result { + let compressed_size = data.len() as u64; + + let decompressed = match encoding { + ContentEncoding::Identity => data, + ContentEncoding::Gzip => decompress_gzip(&data)?, + ContentEncoding::Deflate => decompress_deflate(&data)?, + ContentEncoding::Brotli => decompress_brotli(&data)?, + ContentEncoding::Zstd => decompress_zstd(&data)?, + }; + + let decompressed_size = decompressed.len() as u64; + + Ok(DecompressResult { data: decompressed, compressed_size, decompressed_size }) +} + +fn decompress_gzip(data: &[u8]) -> Result> { + let mut decoder = GzDecoder::new(data); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .map_err(|e| Error::DecompressionError(format!("gzip decompression failed: {}", e)))?; + Ok(decompressed) +} + +fn decompress_deflate(data: &[u8]) -> Result> { + let mut decoder = DeflateDecoder::new(data); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .map_err(|e| Error::DecompressionError(format!("deflate decompression failed: {}", e)))?; + Ok(decompressed) +} + +fn decompress_brotli(data: &[u8]) -> Result> { + let mut decompressed = Vec::new(); + brotli::BrotliDecompress(&mut std::io::Cursor::new(data), &mut decompressed) + .map_err(|e| Error::DecompressionError(format!("brotli decompression failed: {}", e)))?; + Ok(decompressed) +} + +fn decompress_zstd(data: &[u8]) -> Result> { + zstd::stream::decode_all(std::io::Cursor::new(data)) + .map_err(|e| Error::DecompressionError(format!("zstd decompression failed: {}", e))) +} + +/// Create a streaming decompressor that wraps an async reader. +/// Returns an AsyncRead that decompresses data on-the-fly. +pub fn streaming_decoder( + reader: R, + encoding: ContentEncoding, +) -> Box { + match encoding { + ContentEncoding::Identity => Box::new(reader), + ContentEncoding::Gzip => Box::new(GzipDecoder::new(reader)), + ContentEncoding::Deflate => Box::new(AsyncDeflateDecoder::new(reader)), + ContentEncoding::Brotli => Box::new(BrotliDecoder::new(reader)), + ContentEncoding::Zstd => Box::new(AsyncZstdDecoder::new(reader)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use std::io::Write; + + #[test] + fn test_content_encoding_from_header() { + assert_eq!(ContentEncoding::from_header(Some("gzip")), ContentEncoding::Gzip); + assert_eq!(ContentEncoding::from_header(Some("x-gzip")), ContentEncoding::Gzip); + assert_eq!(ContentEncoding::from_header(Some("GZIP")), ContentEncoding::Gzip); + assert_eq!(ContentEncoding::from_header(Some("deflate")), ContentEncoding::Deflate); + assert_eq!(ContentEncoding::from_header(Some("br")), ContentEncoding::Brotli); + assert_eq!(ContentEncoding::from_header(Some("zstd")), ContentEncoding::Zstd); + assert_eq!(ContentEncoding::from_header(Some("identity")), ContentEncoding::Identity); + assert_eq!(ContentEncoding::from_header(Some("unknown")), ContentEncoding::Identity); + assert_eq!(ContentEncoding::from_header(None), ContentEncoding::Identity); + } + + #[test] + fn test_decompress_identity() { + let data = b"hello world".to_vec(); + let result = decompress(data.clone(), ContentEncoding::Identity).unwrap(); + assert_eq!(result.data, data); + assert_eq!(result.compressed_size, 11); + assert_eq!(result.decompressed_size, 11); + } + + #[test] + fn test_decompress_gzip() { + // Compress some data with gzip + let original = b"hello world, this is a test of gzip compression"; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(original).unwrap(); + let compressed = encoder.finish().unwrap(); + + let result = decompress(compressed.clone(), ContentEncoding::Gzip).unwrap(); + assert_eq!(result.data, original); + assert_eq!(result.compressed_size, compressed.len() as u64); + assert_eq!(result.decompressed_size, original.len() as u64); + } + + #[test] + fn test_decompress_deflate() { + // Compress some data with deflate + let original = b"hello world, this is a test of deflate compression"; + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(original).unwrap(); + let compressed = encoder.finish().unwrap(); + + let result = decompress(compressed.clone(), ContentEncoding::Deflate).unwrap(); + assert_eq!(result.data, original); + assert_eq!(result.compressed_size, compressed.len() as u64); + assert_eq!(result.decompressed_size, original.len() as u64); + } + + #[test] + fn test_decompress_brotli() { + // Compress some data with brotli + let original = b"hello world, this is a test of brotli compression"; + let mut compressed = Vec::new(); + let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 4, 22); + writer.write_all(original).unwrap(); + drop(writer); + + let result = decompress(compressed.clone(), ContentEncoding::Brotli).unwrap(); + assert_eq!(result.data, original); + assert_eq!(result.compressed_size, compressed.len() as u64); + assert_eq!(result.decompressed_size, original.len() as u64); + } + + #[test] + fn test_decompress_zstd() { + // Compress some data with zstd + let original = b"hello world, this is a test of zstd compression"; + let compressed = zstd::stream::encode_all(std::io::Cursor::new(original), 3).unwrap(); + + let result = decompress(compressed.clone(), ContentEncoding::Zstd).unwrap(); + assert_eq!(result.data, original); + assert_eq!(result.compressed_size, compressed.len() as u64); + assert_eq!(result.decompressed_size, original.len() as u64); + } +} diff --git a/src-tauri/yaak-http/src/error.rs b/src-tauri/yaak-http/src/error.rs index a4ef6ac0..bfb063a0 100644 --- a/src-tauri/yaak-http/src/error.rs +++ b/src-tauri/yaak-http/src/error.rs @@ -8,6 +8,21 @@ pub enum Error { #[error(transparent)] TlsError(#[from] yaak_tls::error::Error), + + #[error("Request failed with {0:?}")] + RequestError(String), + + #[error("Request canceled")] + RequestCanceledError, + + #[error("Timeout of {0:?} reached")] + RequestTimeout(std::time::Duration), + + #[error("Decompression error: {0}")] + DecompressionError(String), + + #[error("Failed to read response body: {0}")] + BodyReadError(String), } impl Serialize for Error { diff --git a/src-tauri/yaak-http/src/lib.rs b/src-tauri/yaak-http/src/lib.rs index cdc9fff1..387b7419 100644 --- a/src-tauri/yaak-http/src/lib.rs +++ b/src-tauri/yaak-http/src/lib.rs @@ -2,11 +2,17 @@ use crate::manager::HttpConnectionManager; use tauri::plugin::{Builder, TauriPlugin}; use tauri::{Manager, Runtime}; +mod chained_reader; pub mod client; +pub mod decompress; pub mod dns; pub mod error; pub mod manager; pub mod path_placeholders; +mod proto; +pub mod sender; +pub mod transaction; +pub mod types; pub fn init() -> TauriPlugin { Builder::new("yaak-http") diff --git a/src-tauri/yaak-http/src/path_placeholders.rs b/src-tauri/yaak-http/src/path_placeholders.rs index f80b4a5f..9a700e82 100644 --- a/src-tauri/yaak-http/src/path_placeholders.rs +++ b/src-tauri/yaak-http/src/path_placeholders.rs @@ -2,7 +2,7 @@ use yaak_models::models::HttpUrlParameter; pub fn apply_path_placeholders( url: &str, - parameters: Vec, + parameters: &Vec, ) -> (String, Vec) { let mut new_parameters = Vec::new(); @@ -18,7 +18,7 @@ pub fn apply_path_placeholders( // Remove as param if it modified the URL if old_url_string == *url { - new_parameters.push(p); + new_parameters.push(p.to_owned()); } } @@ -156,7 +156,7 @@ mod placeholder_tests { ..Default::default() }; - let (url, url_parameters) = apply_path_placeholders(&req.url, req.url_parameters); + let (url, url_parameters) = apply_path_placeholders(&req.url, &req.url_parameters); // Pattern match back to access it assert_eq!(url, "example.com/aaa/bar"); diff --git a/src-tauri/yaak-http/src/proto.rs b/src-tauri/yaak-http/src/proto.rs new file mode 100644 index 00000000..fd36a0ed --- /dev/null +++ b/src-tauri/yaak-http/src/proto.rs @@ -0,0 +1,29 @@ +use reqwest::Url; +use std::str::FromStr; + +pub(crate) fn ensure_proto(url_str: &str) -> String { + if url_str.is_empty() { + return "".to_string(); + } + + if url_str.starts_with("http://") || url_str.starts_with("https://") { + return url_str.to_string(); + } + + // Url::from_str will fail without a proto, so add one + let parseable_url = format!("http://{}", url_str); + if let Ok(u) = Url::from_str(parseable_url.as_str()) { + match u.host() { + Some(host) => { + let h = host.to_string(); + // These TLDs force HTTPS + if h.ends_with(".app") || h.ends_with(".dev") || h.ends_with(".page") { + return format!("https://{url_str}"); + } + } + None => {} + } + } + + format!("http://{url_str}") +} diff --git a/src-tauri/yaak-http/src/sender.rs b/src-tauri/yaak-http/src/sender.rs new file mode 100644 index 00000000..236dcb45 --- /dev/null +++ b/src-tauri/yaak-http/src/sender.rs @@ -0,0 +1,409 @@ +use crate::decompress::{ContentEncoding, streaming_decoder}; +use crate::error::{Error, Result}; +use crate::types::{SendableBody, SendableHttpRequest}; +use async_trait::async_trait; +use futures_util::StreamExt; +use reqwest::{Client, Method, Version}; +use std::collections::HashMap; +use std::fmt::Display; +use std::pin::Pin; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncRead, AsyncReadExt, BufReader}; +use tokio_util::io::StreamReader; + +#[derive(Debug, Default, Clone)] +pub struct HttpResponseTiming { + pub headers: Duration, + pub body: Duration, +} + +#[derive(Debug)] +pub enum HttpResponseEvent { + Setting(String, String), + Info(String), + SendUrl { method: String, path: String }, + ReceiveUrl { version: Version, status: String }, + HeaderUp(String, String), + HeaderDown(String, String), + HeaderUpDone, + HeaderDownDone, +} + +impl Display for HttpResponseEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value), + HttpResponseEvent::Info(s) => write!(f, "* {}", s), + HttpResponseEvent::SendUrl { method, path } => write!(f, "> {} {}", method, path), + HttpResponseEvent::ReceiveUrl { version, status } => { + write!(f, "< {} {}", version_to_str(version), status) + } + HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value), + HttpResponseEvent::HeaderUpDone => write!(f, ">"), + HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value), + HttpResponseEvent::HeaderDownDone => write!(f, "<"), + } + } +} + +/// Statistics about the body after consumption +#[derive(Debug, Default, Clone)] +pub struct BodyStats { + /// Size of the body as received over the wire (before decompression) + pub size_compressed: u64, + /// Size of the body after decompression + pub size_decompressed: u64, +} + +/// Type alias for the body stream +type BodyStream = Pin>; + +/// HTTP response with deferred body consumption. +/// Headers are available immediately after send(), body can be consumed in different ways. +/// Note: Debug is manually implemented since BodyStream doesn't implement Debug. +pub struct HttpResponse { + /// HTTP status code + pub status: u16, + /// HTTP status reason phrase (e.g., "OK", "Not Found") + pub status_reason: Option, + /// Response headers + pub headers: HashMap, + /// Content-Length from headers (may differ from actual body size) + pub content_length: Option, + /// Final URL (after redirects) + pub url: String, + /// Remote address of the server + pub remote_addr: Option, + /// HTTP version (e.g., "HTTP/1.1", "HTTP/2") + pub version: Option, + /// Timing information + pub timing: HttpResponseTiming, + + /// The body stream (consumed when calling bytes(), text(), write_to_file(), or drain()) + body_stream: Option, + /// Content-Encoding for decompression + encoding: ContentEncoding, + /// Start time for timing the body read + start_time: Instant, +} + +impl std::fmt::Debug for HttpResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpResponse") + .field("status", &self.status) + .field("status_reason", &self.status_reason) + .field("headers", &self.headers) + .field("content_length", &self.content_length) + .field("url", &self.url) + .field("remote_addr", &self.remote_addr) + .field("version", &self.version) + .field("timing", &self.timing) + .field("body_stream", &"") + .field("encoding", &self.encoding) + .finish() + } +} + +impl HttpResponse { + /// Create a new HttpResponse with an unconsumed body stream + #[allow(clippy::too_many_arguments)] + pub fn new( + status: u16, + status_reason: Option, + headers: HashMap, + content_length: Option, + url: String, + remote_addr: Option, + version: Option, + timing: HttpResponseTiming, + body_stream: BodyStream, + encoding: ContentEncoding, + start_time: Instant, + ) -> Self { + Self { + status, + status_reason, + headers, + content_length, + url, + remote_addr, + version, + timing, + body_stream: Some(body_stream), + encoding, + start_time, + } + } + + /// Consume the body and return it as bytes (loads entire body into memory). + /// Also decompresses the body if Content-Encoding is set. + pub async fn bytes(mut self) -> Result<(Vec, BodyStats, HttpResponseTiming)> { + let stream = self.body_stream.take().ok_or_else(|| { + Error::RequestError("Response body has already been consumed".to_string()) + })?; + + let buf_reader = BufReader::new(stream); + let mut decoder = streaming_decoder(buf_reader, self.encoding); + + let mut decompressed = Vec::new(); + let mut bytes_read = 0u64; + + // Read through the decoder in chunks to track compressed size + let mut buf = [0u8; 8192]; + loop { + match decoder.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + decompressed.extend_from_slice(&buf[..n]); + bytes_read += n as u64; + } + Err(e) => { + return Err(Error::BodyReadError(e.to_string())); + } + } + } + + let mut timing = self.timing.clone(); + timing.body = self.start_time.elapsed(); + + let stats = BodyStats { + // For now, we can't easily track compressed size when streaming through decoder + // Use content_length as an approximation, or decompressed size if identity encoding + size_compressed: self.content_length.unwrap_or(bytes_read), + size_decompressed: decompressed.len() as u64, + }; + + Ok((decompressed, stats, timing)) + } + + /// Consume the body and return it as a UTF-8 string. + pub async fn text(self) -> Result<(String, BodyStats, HttpResponseTiming)> { + let (bytes, stats, timing) = self.bytes().await?; + let text = String::from_utf8(bytes) + .map_err(|e| Error::RequestError(format!("Response is not valid UTF-8: {}", e)))?; + Ok((text, stats, timing)) + } + + /// Take the body stream for manual consumption. + /// Returns an AsyncRead that decompresses on-the-fly if Content-Encoding is set. + /// The caller is responsible for reading and processing the stream. + pub fn into_body_stream(mut self) -> Result> { + let stream = self.body_stream.take().ok_or_else(|| { + Error::RequestError("Response body has already been consumed".to_string()) + })?; + + let buf_reader = BufReader::new(stream); + let decoder = streaming_decoder(buf_reader, self.encoding); + + Ok(decoder) + } + + /// Discard the body without reading it (useful for redirects). + pub async fn drain(mut self) -> Result { + let stream = self.body_stream.take().ok_or_else(|| { + Error::RequestError("Response body has already been consumed".to_string()) + })?; + + // Just read and discard all bytes + let mut reader = stream; + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf).await { + Ok(0) => break, + Ok(_) => continue, + Err(e) => { + return Err(Error::RequestError(format!( + "Failed to drain response body: {}", + e + ))); + } + } + } + + let mut timing = self.timing.clone(); + timing.body = self.start_time.elapsed(); + + Ok(timing) + } +} + +/// Trait for sending HTTP requests +#[async_trait] +pub trait HttpSender: Send + Sync { + /// Send an HTTP request and return the response with headers. + /// The body is not consumed until you call bytes(), text(), write_to_file(), or drain(). + async fn send( + &self, + request: SendableHttpRequest, + events: &mut Vec, + ) -> Result; +} + +/// Reqwest-based implementation of HttpSender +pub struct ReqwestSender { + client: Client, +} + +impl ReqwestSender { + /// Create a new ReqwestSender with a default client + pub fn new() -> Result { + let client = Client::builder().build().map_err(Error::Client)?; + Ok(Self { client }) + } + + /// Create a new ReqwestSender with a custom client + pub fn with_client(client: Client) -> Self { + Self { client } + } +} + +#[async_trait] +impl HttpSender for ReqwestSender { + async fn send( + &self, + request: SendableHttpRequest, + events: &mut Vec, + ) -> Result { + // Parse the HTTP method + let method = Method::from_bytes(request.method.as_bytes()) + .map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?; + + // Build the request + let mut req_builder = self.client.request(method, &request.url); + + // Add headers + for header in request.headers { + req_builder = req_builder.header(&header.0, &header.1); + } + + // Configure timeout + if let Some(d) = request.options.timeout + && !d.is_zero() + { + req_builder = req_builder.timeout(d); + } + + // Add body + match request.body { + None => {} + Some(SendableBody::Bytes(bytes)) => { + req_builder = req_builder.body(bytes); + } + Some(SendableBody::Stream(stream)) => { + // Convert AsyncRead stream to reqwest Body + let stream = tokio_util::io::ReaderStream::new(stream); + let body = reqwest::Body::wrap_stream(stream); + req_builder = req_builder.body(body); + } + } + + let start = Instant::now(); + let mut timing = HttpResponseTiming::default(); + + // Send the request + let sendable_req = req_builder.build()?; + events.push(HttpResponseEvent::Setting( + "timeout".to_string(), + if request.options.timeout.unwrap_or_default().is_zero() { + "Infinity".to_string() + } else { + format!("{:?}", request.options.timeout) + }, + )); + + events.push(HttpResponseEvent::SendUrl { + path: sendable_req.url().path().to_string(), + method: sendable_req.method().to_string(), + }); + + for (name, value) in sendable_req.headers() { + events.push(HttpResponseEvent::HeaderUp( + name.to_string(), + value.to_str().unwrap_or_default().to_string(), + )); + } + events.push(HttpResponseEvent::HeaderUpDone); + events.push(HttpResponseEvent::Info("Sending request to server".to_string())); + + // Map some errors to our own, so they look nicer + let response = self.client.execute(sendable_req).await.map_err(|e| { + if reqwest::Error::is_timeout(&e) { + Error::RequestTimeout( + request.options.timeout.unwrap_or(Duration::from_secs(0)).clone(), + ) + } else { + Error::Client(e) + } + })?; + + let status = response.status().as_u16(); + let status_reason = response.status().canonical_reason().map(|s| s.to_string()); + let url = response.url().to_string(); + let remote_addr = response.remote_addr().map(|a| a.to_string()); + let version = Some(version_to_str(&response.version())); + + events.push(HttpResponseEvent::ReceiveUrl { + version: response.version(), + status: response.status().to_string(), + }); + + timing.headers = start.elapsed(); + + // Extract content length + let content_length = response.content_length(); + + // Extract headers + let mut headers = HashMap::new(); + for (key, value) in response.headers() { + if let Ok(v) = value.to_str() { + events.push(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string())); + headers.insert(key.to_string(), v.to_string()); + } + } + events.push(HttpResponseEvent::HeaderDownDone); + + // Determine content encoding for decompression + // HTTP headers are case-insensitive, so we need to search for any casing + let encoding = ContentEncoding::from_header( + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("content-encoding")) + .map(|(_, v)| v.as_str()), + ); + + // Get the byte stream instead of loading into memory + let byte_stream = response.bytes_stream(); + + // Convert the stream to an AsyncRead + let stream_reader = StreamReader::new( + byte_stream.map(|result| result.map_err(|e| std::io::Error::other(e))), + ); + + let body_stream: BodyStream = Box::pin(stream_reader); + + Ok(HttpResponse::new( + status, + status_reason, + headers, + content_length, + url, + remote_addr, + version, + timing, + body_stream, + encoding, + start, + )) + } +} + +fn version_to_str(version: &Version) -> String { + match *version { + Version::HTTP_09 => "HTTP/0.9".to_string(), + Version::HTTP_10 => "HTTP/1.0".to_string(), + Version::HTTP_11 => "HTTP/1.1".to_string(), + Version::HTTP_2 => "HTTP/2".to_string(), + Version::HTTP_3 => "HTTP/3".to_string(), + _ => "unknown".to_string(), + } +} diff --git a/src-tauri/yaak-http/src/transaction.rs b/src-tauri/yaak-http/src/transaction.rs new file mode 100644 index 00000000..de4da72a --- /dev/null +++ b/src-tauri/yaak-http/src/transaction.rs @@ -0,0 +1,385 @@ +use crate::error::Result; +use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender}; +use crate::types::SendableHttpRequest; +use tokio::sync::watch::Receiver; + +/// HTTP Transaction that manages the lifecycle of a request, including redirect handling +pub struct HttpTransaction { + sender: S, + max_redirects: usize, +} + +impl HttpTransaction { + /// Create a new transaction with default settings + pub fn new(sender: S) -> Self { + Self { sender, max_redirects: 10 } + } + + /// Create a new transaction with custom max redirects + pub fn with_max_redirects(sender: S, max_redirects: usize) -> Self { + Self { sender, max_redirects } + } + + /// Execute the request with cancellation support. + /// Returns an HttpResponse with unconsumed body - caller decides how to consume it. + pub async fn execute_with_cancellation( + &self, + request: SendableHttpRequest, + mut cancelled_rx: Receiver, + ) -> Result<(HttpResponse, Vec)> { + let mut redirect_count = 0; + let mut current_url = request.url; + let mut current_method = request.method; + let mut current_headers = request.headers; + let mut current_body = request.body; + let mut events = Vec::new(); + + loop { + // Check for cancellation before each request + if *cancelled_rx.borrow() { + return Err(crate::error::Error::RequestCanceledError); + } + + // Build request for this iteration + let req = SendableHttpRequest { + url: current_url.clone(), + method: current_method.clone(), + headers: current_headers.clone(), + body: current_body, + options: request.options.clone(), + }; + + // Send the request + events.push(HttpResponseEvent::Setting( + "redirects".to_string(), + request.options.follow_redirects.to_string(), + )); + + // Execute with cancellation support + let response = tokio::select! { + result = self.sender.send(req, &mut events) => result?, + _ = cancelled_rx.changed() => { + return Err(crate::error::Error::RequestCanceledError); + } + }; + + if !Self::is_redirect(response.status) { + // Not a redirect - return the response for caller to consume body + return Ok((response, events)); + } + + if !request.options.follow_redirects { + // Redirects disabled - return the redirect response as-is + return Ok((response, events)); + } + + // Check if we've exceeded max redirects + if redirect_count >= self.max_redirects { + // Drain the response before returning error + let _ = response.drain().await; + return Err(crate::error::Error::RequestError(format!( + "Maximum redirect limit ({}) exceeded", + self.max_redirects + ))); + } + + // Extract Location header before draining (headers are available immediately) + // HTTP headers are case-insensitive, so we need to search for any casing + let location = response + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("location")) + .map(|(_, v)| v.clone()) + .ok_or_else(|| { + crate::error::Error::RequestError( + "Redirect response missing Location header".to_string(), + ) + })?; + + // Also get status before draining + let status = response.status; + + events.push(HttpResponseEvent::Info("Ignoring the response body".to_string())); + + // Drain the redirect response body before following + response.drain().await?; + + // Update the request URL + current_url = if location.starts_with("http://") || location.starts_with("https://") { + // Absolute URL + location + } else if location.starts_with('/') { + // Absolute path - need to extract base URL from current request + let base_url = Self::extract_base_url(¤t_url)?; + format!("{}{}", base_url, location) + } else { + // Relative path - need to resolve relative to current path + let base_path = Self::extract_base_path(¤t_url)?; + format!("{}/{}", base_path, location) + }; + + events.push(HttpResponseEvent::Info(format!( + "Issuing redirect {} to: {}", + redirect_count + 1, + current_url + ))); + + // Handle method changes for certain redirect codes + if status == 303 { + // 303 See Other always changes to GET + if current_method != "GET" { + current_method = "GET".to_string(); + events.push(HttpResponseEvent::Info("Changing method to GET".to_string())); + } + // Remove content-related headers + current_headers.retain(|h| { + let name_lower = h.0.to_lowercase(); + !name_lower.starts_with("content-") && name_lower != "transfer-encoding" + }); + } else if status == 301 || status == 302 { + // For 301/302, change POST to GET (common browser behavior) + // but keep other methods as-is + if current_method == "POST" { + events.push(HttpResponseEvent::Info("Changing method to GET".to_string())); + current_method = "GET".to_string(); + // Remove content-related headers + current_headers.retain(|h| { + let name_lower = h.0.to_lowercase(); + !name_lower.starts_with("content-") && name_lower != "transfer-encoding" + }); + } + } + // For 307 and 308, the method and body are preserved + + // Reset body for next iteration (since it was moved in the send call) + // For redirects that change method to GET or for all redirects since body was consumed + current_body = None; + + redirect_count += 1; + } + } + + /// Check if a status code indicates a redirect + fn is_redirect(status: u16) -> bool { + matches!(status, 301 | 302 | 303 | 307 | 308) + } + + /// Extract the base URL (scheme + host) from a full URL + fn extract_base_url(url: &str) -> Result { + // Find the position after "://" + let scheme_end = url.find("://").ok_or_else(|| { + crate::error::Error::RequestError(format!("Invalid URL format: {}", url)) + })?; + + // Find the first '/' after the scheme + let path_start = url[scheme_end + 3..].find('/'); + + if let Some(idx) = path_start { + Ok(url[..scheme_end + 3 + idx].to_string()) + } else { + // No path, return entire URL + Ok(url.to_string()) + } + } + + /// Extract the base path (everything except the last segment) from a URL + fn extract_base_path(url: &str) -> Result { + if let Some(last_slash) = url.rfind('/') { + // Don't include the trailing slash if it's part of the host + if url[..last_slash].ends_with("://") || url[..last_slash].ends_with(':') { + Ok(url.to_string()) + } else { + Ok(url[..last_slash].to_string()) + } + } else { + Ok(url.to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decompress::ContentEncoding; + use crate::sender::{HttpResponseEvent, HttpResponseTiming, HttpSender}; + use async_trait::async_trait; + use std::collections::HashMap; + use std::pin::Pin; + use std::sync::Arc; + use std::time::Instant; + use tokio::io::AsyncRead; + use tokio::sync::Mutex; + + /// Mock sender for testing + struct MockSender { + responses: Arc>>, + } + + struct MockResponse { + status: u16, + headers: HashMap, + body: Vec, + } + + impl MockSender { + fn new(responses: Vec) -> Self { + Self { responses: Arc::new(Mutex::new(responses)) } + } + } + + #[async_trait] + impl HttpSender for MockSender { + async fn send( + &self, + _request: SendableHttpRequest, + _events: &mut Vec, + ) -> Result { + let mut responses = self.responses.lock().await; + if responses.is_empty() { + Err(crate::error::Error::RequestError("No more mock responses".to_string())) + } else { + let mock = responses.remove(0); + // Create a simple in-memory stream from the body + let body_stream: Pin> = + Box::pin(std::io::Cursor::new(mock.body)); + Ok(HttpResponse::new( + mock.status, + None, // status_reason + mock.headers, + None, // content_length + "https://example.com".to_string(), // url + None, // remote_addr + Some("HTTP/1.1".to_string()), // version + HttpResponseTiming::default(), + body_stream, + ContentEncoding::Identity, + Instant::now(), + )) + } + } + } + + #[tokio::test] + async fn test_transaction_no_redirect() { + let response = MockResponse { status: 200, headers: HashMap::new(), body: b"OK".to_vec() }; + let sender = MockSender::new(vec![response]); + let transaction = HttpTransaction::new(sender); + + let request = SendableHttpRequest { + url: "https://example.com".to_string(), + method: "GET".to_string(), + headers: vec![], + ..Default::default() + }; + + let (_tx, rx) = tokio::sync::watch::channel(false); + let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); + assert_eq!(result.status, 200); + + // Consume the body to verify it + let (body, _, _) = result.bytes().await.unwrap(); + assert_eq!(body, b"OK"); + } + + #[tokio::test] + async fn test_transaction_single_redirect() { + let mut redirect_headers = HashMap::new(); + redirect_headers.insert("Location".to_string(), "https://example.com/new".to_string()); + + let responses = vec![ + MockResponse { status: 302, headers: redirect_headers, body: vec![] }, + MockResponse { status: 200, headers: HashMap::new(), body: b"Final".to_vec() }, + ]; + + let sender = MockSender::new(responses); + let transaction = HttpTransaction::new(sender); + + let request = SendableHttpRequest { + url: "https://example.com/old".to_string(), + method: "GET".to_string(), + options: crate::types::SendableHttpRequestOptions { + follow_redirects: true, + ..Default::default() + }, + ..Default::default() + }; + + let (_tx, rx) = tokio::sync::watch::channel(false); + let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); + assert_eq!(result.status, 200); + + let (body, _, _) = result.bytes().await.unwrap(); + assert_eq!(body, b"Final"); + } + + #[tokio::test] + async fn test_transaction_max_redirects_exceeded() { + let mut redirect_headers = HashMap::new(); + redirect_headers.insert("Location".to_string(), "https://example.com/loop".to_string()); + + // Create more redirects than allowed + let responses: Vec = (0..12) + .map(|_| MockResponse { status: 302, headers: redirect_headers.clone(), body: vec![] }) + .collect(); + + let sender = MockSender::new(responses); + let transaction = HttpTransaction::with_max_redirects(sender, 10); + + let request = SendableHttpRequest { + url: "https://example.com/start".to_string(), + method: "GET".to_string(), + options: crate::types::SendableHttpRequestOptions { + follow_redirects: true, + ..Default::default() + }, + ..Default::default() + }; + + let (_tx, rx) = tokio::sync::watch::channel(false); + let result = transaction.execute_with_cancellation(request, rx).await; + if let Err(crate::error::Error::RequestError(msg)) = result { + assert!(msg.contains("Maximum redirect limit")); + } else { + panic!("Expected RequestError with max redirect message. Got {result:?}"); + } + } + + #[test] + fn test_is_redirect() { + assert!(HttpTransaction::::is_redirect(301)); + assert!(HttpTransaction::::is_redirect(302)); + assert!(HttpTransaction::::is_redirect(303)); + assert!(HttpTransaction::::is_redirect(307)); + assert!(HttpTransaction::::is_redirect(308)); + assert!(!HttpTransaction::::is_redirect(200)); + assert!(!HttpTransaction::::is_redirect(404)); + assert!(!HttpTransaction::::is_redirect(500)); + } + + #[test] + fn test_extract_base_url() { + let result = + HttpTransaction::::extract_base_url("https://example.com/path/to/resource"); + assert_eq!(result.unwrap(), "https://example.com"); + + let result = HttpTransaction::::extract_base_url("http://localhost:8080/api"); + assert_eq!(result.unwrap(), "http://localhost:8080"); + + let result = HttpTransaction::::extract_base_url("invalid-url"); + assert!(result.is_err()); + } + + #[test] + fn test_extract_base_path() { + let result = HttpTransaction::::extract_base_path( + "https://example.com/path/to/resource", + ); + assert_eq!(result.unwrap(), "https://example.com/path/to"); + + let result = HttpTransaction::::extract_base_path("https://example.com/single"); + assert_eq!(result.unwrap(), "https://example.com"); + + let result = HttpTransaction::::extract_base_path("https://example.com/"); + assert_eq!(result.unwrap(), "https://example.com"); + } +} diff --git a/src-tauri/yaak-http/src/types.rs b/src-tauri/yaak-http/src/types.rs new file mode 100644 index 00000000..cd3432f4 --- /dev/null +++ b/src-tauri/yaak-http/src/types.rs @@ -0,0 +1,975 @@ +use crate::chained_reader::{ChainedReader, ReaderType}; +use crate::error::Error::RequestError; +use crate::error::Result; +use crate::path_placeholders::apply_path_placeholders; +use crate::proto::ensure_proto; +use bytes::Bytes; +use log::warn; +use std::collections::BTreeMap; +use std::pin::Pin; +use std::time::Duration; +use tokio::io::AsyncRead; +use yaak_common::serde::{get_bool, get_str, get_str_map}; +use yaak_models::models::HttpRequest; + +pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary"; + +pub enum SendableBody { + Bytes(Bytes), + Stream(Pin>), +} + +enum SendableBodyWithMeta { + Bytes(Bytes), + Stream { + data: Pin>, + content_length: Option, + }, +} + +impl From for SendableBody { + fn from(value: SendableBodyWithMeta) -> Self { + match value { + SendableBodyWithMeta::Bytes(b) => SendableBody::Bytes(b), + SendableBodyWithMeta::Stream { data, .. } => SendableBody::Stream(data), + } + } +} + +#[derive(Default)] +pub struct SendableHttpRequest { + pub url: String, + pub method: String, + pub headers: Vec<(String, String)>, + pub body: Option, + pub options: SendableHttpRequestOptions, +} + +#[derive(Default, Clone)] +pub struct SendableHttpRequestOptions { + pub timeout: Option, + pub follow_redirects: bool, +} + +impl SendableHttpRequest { + pub async fn from_http_request( + r: &HttpRequest, + options: SendableHttpRequestOptions, + ) -> Result { + let initial_headers = build_headers(r); + let (body, headers) = build_body(&r.method, &r.body_type, &r.body, initial_headers).await?; + + Ok(Self { + url: build_url(r), + method: r.method.to_uppercase(), + headers, + body: body.into(), + options, + }) + } + + pub fn insert_header(&mut self, header: (String, String)) { + if let Some(existing) = + self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase()) + { + existing.1 = header.1; + } else { + self.headers.push(header); + } + } +} + +pub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String { + let url_string = url.to_string(); + if params.is_empty() { + return url.to_string(); + } + + // Build query string + let query_string = params + .iter() + .map(|(name, value)| { + format!("{}={}", urlencoding::encode(name), urlencoding::encode(value)) + }) + .collect::>() + .join("&"); + + // Split URL into parts: base URL, query, and fragment + let (base_and_query, fragment) = if let Some(hash_pos) = url_string.find('#') { + let (before_hash, after_hash) = url_string.split_at(hash_pos); + (before_hash.to_string(), Some(after_hash.to_string())) + } else { + (url_string, None) + }; + + // Now handle query parameters on the base URL (without fragment) + let mut result = if base_and_query.contains('?') { + // Check if there's already a query string after the '?' + let parts: Vec<&str> = base_and_query.splitn(2, '?').collect(); + if parts.len() == 2 && !parts[1].trim().is_empty() { + // Append with & if there are existing parameters + format!("{}&{}", base_and_query, query_string) + } else { + // Just append the new parameters directly (URL ends with '?') + format!("{}{}", base_and_query, query_string) + } + } else { + // No existing query parameters, add with '?' + format!("{}?{}", base_and_query, query_string) + }; + + // Re-append the fragment if it exists + if let Some(fragment) = fragment { + result.push_str(&fragment); + } + + result +} + +fn build_url(r: &HttpRequest) -> String { + let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters); + append_query_params( + &url_string, + params + .iter() + .filter(|p| p.enabled && !p.name.is_empty()) + .map(|p| (p.name.clone(), p.value.clone())) + .collect(), + ) +} + +fn build_headers(r: &HttpRequest) -> Vec<(String, String)> { + r.headers + .iter() + .filter_map(|h| { + if h.enabled && !h.name.is_empty() { + Some((h.name.clone(), h.value.clone())) + } else { + None + } + }) + .collect() +} + +async fn build_body( + method: &str, + body_type: &Option, + body: &BTreeMap, + headers: Vec<(String, String)>, +) -> Result<(Option, Vec<(String, String)>)> { + let body_type = match &body_type { + None => return Ok((None, headers)), + Some(t) => t, + }; + + let (body, content_type) = match body_type.as_str() { + "binary" => (build_binary_body(&body).await?, None), + "graphql" => (build_graphql_body(&method, &body), Some("application/json".to_string())), + "application/x-www-form-urlencoded" => { + (build_form_body(&body), Some("application/x-www-form-urlencoded".to_string())) + } + "multipart/form-data" => build_multipart_body(&body, &headers).await?, + _ if body.contains_key("text") => (build_text_body(&body), None), + t => { + warn!("Unsupported body type: {}", t); + (None, None) + } + }; + + // Add or update the Content-Type header + let mut headers = headers; + if let Some(ct) = content_type { + if let Some(existing) = headers.iter_mut().find(|h| h.0.to_lowercase() == "content-type") { + existing.1 = ct; + } else { + headers.push(("Content-Type".to_string(), ct)); + } + } + + // Check if Transfer-Encoding: chunked is already set + let has_chunked_encoding = headers.iter().any(|h| { + h.0.to_lowercase() == "transfer-encoding" && h.1.to_lowercase().contains("chunked") + }); + + // Add a Content-Length header only if chunked encoding is not being used + if !has_chunked_encoding { + let content_length = match body { + Some(SendableBodyWithMeta::Bytes(ref bytes)) => Some(bytes.len()), + Some(SendableBodyWithMeta::Stream { content_length, .. }) => content_length, + None => None, + }; + + if let Some(cl) = content_length { + headers.push(("Content-Length".to_string(), cl.to_string())); + } + } + + Ok((body.map(|b| b.into()), headers)) +} + +fn build_form_body(body: &BTreeMap) -> Option { + let form_params = match body.get("form").map(|f| f.as_array()) { + Some(Some(f)) => f, + _ => return None, + }; + + let mut body = String::new(); + for p in form_params { + let enabled = get_bool(p, "enabled", true); + let name = get_str(p, "name"); + if !enabled || name.is_empty() { + continue; + } + let value = get_str(p, "value"); + if !body.is_empty() { + body.push('&'); + } + body.push_str(&urlencoding::encode(&name)); + body.push('='); + body.push_str(&urlencoding::encode(&value)); + } + + if body.is_empty() { None } else { Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) } +} + +async fn build_binary_body( + body: &BTreeMap, +) -> Result> { + let file_path = match body.get("filePath").map(|f| f.as_str()) { + Some(Some(f)) => f, + _ => return Ok(None), + }; + + // Open a file for streaming + let content_length = tokio::fs::metadata(file_path) + .await + .map_err(|e| RequestError(format!("Failed to get file metadata: {}", e)))? + .len(); + + let file = tokio::fs::File::open(file_path) + .await + .map_err(|e| RequestError(format!("Failed to open file: {}", e)))?; + + Ok(Some(SendableBodyWithMeta::Stream { + data: Box::pin(file), + content_length: Some(content_length as usize), + })) +} + +fn build_text_body(body: &BTreeMap) -> Option { + let text = get_str_map(body, "text"); + if text.is_empty() { + None + } else { + Some(SendableBodyWithMeta::Bytes(Bytes::from(text.to_string()))) + } +} + +fn build_graphql_body( + method: &str, + body: &BTreeMap, +) -> Option { + let query = get_str_map(body, "query"); + let variables = get_str_map(body, "variables"); + + if method.to_lowercase() == "get" { + // GraphQL GET requests use query parameters, not a body + return None; + } + + let body = if variables.trim().is_empty() { + format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default()) + } else { + format!( + r#"{{"query":{},"variables":{}}}"#, + serde_json::to_string(&query).unwrap_or_default(), + variables + ) + }; + + Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) +} + +async fn build_multipart_body( + body: &BTreeMap, + headers: &Vec<(String, String)>, +) -> Result<(Option, Option)> { + let boundary = extract_boundary_from_headers(headers); + + let form_params = match body.get("form").map(|f| f.as_array()) { + Some(Some(f)) => f, + _ => return Ok((None, None)), + }; + + // Build a list of readers for streaming and calculate total content length + let mut readers: Vec = Vec::new(); + let mut has_content = false; + let mut total_size: usize = 0; + + for p in form_params { + let enabled = get_bool(p, "enabled", true); + let name = get_str(p, "name"); + if !enabled || name.is_empty() { + continue; + } + + has_content = true; + + // Add boundary delimiter + let boundary_bytes = format!("--{}\r\n", boundary).into_bytes(); + total_size += boundary_bytes.len(); + readers.push(ReaderType::Bytes(boundary_bytes)); + + let file_path = get_str(p, "file"); + let value = get_str(p, "value"); + let content_type = get_str(p, "contentType"); + + if file_path.is_empty() { + // Text field + let header = + format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}", name, value); + let header_bytes = header.into_bytes(); + total_size += header_bytes.len(); + readers.push(ReaderType::Bytes(header_bytes)); + } else { + // File field - validate that file exists first + if !tokio::fs::try_exists(file_path).await.unwrap_or(false) { + return Err(RequestError(format!("File not found: {}", file_path))); + } + + // Get file size for content length calculation + let file_metadata = tokio::fs::metadata(file_path) + .await + .map_err(|e| RequestError(format!("Failed to get file metadata: {}", e)))?; + let file_size = file_metadata.len() as usize; + + let filename = get_str(p, "filename"); + let filename = if filename.is_empty() { + std::path::Path::new(file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + } else { + filename + }; + + // Add content type + let mime_type = if !content_type.is_empty() { + content_type.to_string() + } else { + // Guess mime type from file extension + mime_guess::from_path(file_path).first_or_octet_stream().to_string() + }; + + let header = format!( + "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", + name, filename, mime_type + ); + let header_bytes = header.into_bytes(); + total_size += header_bytes.len(); + total_size += file_size; + readers.push(ReaderType::Bytes(header_bytes)); + + // Add a file path for streaming + readers.push(ReaderType::FilePath(file_path.to_string())); + } + + let line_ending = b"\r\n".to_vec(); + total_size += line_ending.len(); + readers.push(ReaderType::Bytes(line_ending)); + } + + if has_content { + // Add the final boundary + let final_boundary = format!("--{}--\r\n", boundary).into_bytes(); + total_size += final_boundary.len(); + readers.push(ReaderType::Bytes(final_boundary)); + + let content_type = format!("multipart/form-data; boundary={}", boundary); + let stream = ChainedReader::new(readers); + Ok(( + Some(SendableBodyWithMeta::Stream { + data: Box::pin(stream), + content_length: Some(total_size), + }), + Some(content_type), + )) + } else { + Ok((None, None)) + } +} + +fn extract_boundary_from_headers(headers: &Vec<(String, String)>) -> String { + headers + .iter() + .find(|h| h.0.to_lowercase() == "content-type") + .and_then(|h| { + // Extract boundary from the Content-Type header (e.g., "multipart/form-data; boundary=xyz") + h.1.split(';') + .find(|part| part.trim().starts_with("boundary=")) + .and_then(|boundary_part| boundary_part.split('=').nth(1)) + .map(|b| b.trim().to_string()) + }) + .unwrap_or_else(|| MULTIPART_BOUNDARY.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use serde_json::json; + use std::collections::BTreeMap; + use yaak_models::models::{HttpRequest, HttpUrlParameter}; + + #[test] + fn test_build_url_no_params() { + let r = HttpRequest { + url: "https://example.com/api".to_string(), + url_parameters: vec![], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api"); + } + + #[test] + fn test_build_url_with_params() { + let r = HttpRequest { + url: "https://example.com/api".to_string(), + url_parameters: vec![ + HttpUrlParameter { + enabled: true, + name: "foo".to_string(), + value: "bar".to_string(), + id: None, + }, + HttpUrlParameter { + enabled: true, + name: "baz".to_string(), + value: "qux".to_string(), + id: None, + }, + ], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api?foo=bar&baz=qux"); + } + + #[test] + fn test_build_url_with_disabled_params() { + let r = HttpRequest { + url: "https://example.com/api".to_string(), + url_parameters: vec![ + HttpUrlParameter { + enabled: false, + name: "disabled".to_string(), + value: "value".to_string(), + id: None, + }, + HttpUrlParameter { + enabled: true, + name: "enabled".to_string(), + value: "value".to_string(), + id: None, + }, + ], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api?enabled=value"); + } + + #[test] + fn test_build_url_with_existing_query() { + let r = HttpRequest { + url: "https://example.com/api?existing=param".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "new".to_string(), + value: "value".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api?existing=param&new=value"); + } + + #[test] + fn test_build_url_with_empty_existing_query() { + let r = HttpRequest { + url: "https://example.com/api?".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "new".to_string(), + value: "value".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api?new=value"); + } + + #[test] + fn test_build_url_with_special_chars() { + let r = HttpRequest { + url: "https://example.com/api".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "special chars!@#".to_string(), + value: "value with spaces & symbols".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!( + result, + "https://example.com/api?special%20chars%21%40%23=value%20with%20spaces%20%26%20symbols" + ); + } + + #[test] + fn test_build_url_adds_protocol() { + let r = HttpRequest { + url: "example.com/api".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "foo".to_string(), + value: "bar".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + // ensure_proto defaults to http:// for regular domains + assert_eq!(result, "http://example.com/api?foo=bar"); + } + + #[test] + fn test_build_url_adds_https_for_dev_domain() { + let r = HttpRequest { + url: "example.dev/api".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "foo".to_string(), + value: "bar".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + // .dev domains force https + assert_eq!(result, "https://example.dev/api?foo=bar"); + } + + #[test] + fn test_build_url_with_fragment() { + let r = HttpRequest { + url: "https://example.com/api#section".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "foo".to_string(), + value: "bar".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api?foo=bar#section"); + } + + #[test] + fn test_build_url_with_existing_query_and_fragment() { + let r = HttpRequest { + url: "https://yaak.app?foo=bar#some-hash".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "baz".to_string(), + value: "qux".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://yaak.app?foo=bar&baz=qux#some-hash"); + } + + #[test] + fn test_build_url_with_empty_query_and_fragment() { + let r = HttpRequest { + url: "https://example.com/api?#section".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "foo".to_string(), + value: "bar".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api?foo=bar#section"); + } + + #[test] + fn test_build_url_with_fragment_containing_special_chars() { + let r = HttpRequest { + url: "https://example.com#section/with/slashes?and=fake&query".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "real".to_string(), + value: "param".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com?real=param#section/with/slashes?and=fake&query"); + } + + #[test] + fn test_build_url_preserves_empty_fragment() { + let r = HttpRequest { + url: "https://example.com/api#".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "foo".to_string(), + value: "bar".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!(result, "https://example.com/api?foo=bar#"); + } + + #[test] + fn test_build_url_with_multiple_fragments() { + // Testing edge case where the URL has multiple # characters (though technically invalid) + let r = HttpRequest { + url: "https://example.com#section#subsection".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "foo".to_string(), + value: "bar".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + // Should treat everything after first # as fragment + assert_eq!(result, "https://example.com?foo=bar#section#subsection"); + } + + #[tokio::test] + async fn test_text_body() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("Hello, World!")); + + let result = build_text_body(&body); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + assert_eq!(bytes, Bytes::from("Hello, World!")) + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_text_body_empty() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("")); + + let result = build_text_body(&body); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_text_body_missing() { + let body = BTreeMap::new(); + + let result = build_text_body(&body); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_form_urlencoded_body() -> Result<()> { + let mut body = BTreeMap::new(); + body.insert( + "form".to_string(), + json!([ + { "enabled": true, "name": "basic", "value": "aaa"}, + { "enabled": true, "name": "fUnkey Stuff!$*#(", "value": "*)%&#$)@ *$#)@&"}, + { "enabled": false, "name": "disabled", "value": "won't show"}, + ]), + ); + + let result = build_form_body(&body); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let expected = "basic=aaa&fUnkey%20Stuff%21%24%2A%23%28=%2A%29%25%26%23%24%29%40%20%2A%24%23%29%40%26"; + assert_eq!(bytes, Bytes::from(expected)); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + Ok(()) + } + + #[tokio::test] + async fn test_form_urlencoded_body_missing_form() { + let body = BTreeMap::new(); + let result = build_form_body(&body); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_binary_body() -> Result<()> { + let mut body = BTreeMap::new(); + body.insert("filePath".to_string(), json!("./tests/test.txt")); + + let result = build_binary_body(&body).await?; + assert!(matches!(result, Some(SendableBodyWithMeta::Stream { .. }))); + Ok(()) + } + + #[tokio::test] + async fn test_binary_body_file_not_found() { + let mut body = BTreeMap::new(); + body.insert("filePath".to_string(), json!("./nonexistent/file.txt")); + + let result = build_binary_body(&body).await; + assert!(result.is_err()); + if let Err(e) = result { + assert!(matches!(e, RequestError(_))); + } + } + + #[tokio::test] + async fn test_graphql_body_with_variables() { + let mut body = BTreeMap::new(); + body.insert("query".to_string(), json!("{ user(id: $id) { name } }")); + body.insert("variables".to_string(), json!(r#"{"id": "123"}"#)); + + let result = build_graphql_body("POST", &body); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let expected = + r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#; + assert_eq!(bytes, Bytes::from(expected)); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_graphql_body_without_variables() { + let mut body = BTreeMap::new(); + body.insert("query".to_string(), json!("{ users { name } }")); + body.insert("variables".to_string(), json!("")); + + let result = build_graphql_body("POST", &body); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let expected = r#"{"query":"{ users { name } }"}"#; + assert_eq!(bytes, Bytes::from(expected)); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_graphql_body_get_method() { + let mut body = BTreeMap::new(); + body.insert("query".to_string(), json!("{ users { name } }")); + + let result = build_graphql_body("GET", &body); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_multipart_body_text_fields() -> Result<()> { + let mut body = BTreeMap::new(); + body.insert( + "form".to_string(), + json!([ + { "enabled": true, "name": "field1", "value": "value1", "file": "" }, + { "enabled": true, "name": "field2", "value": "value2", "file": "" }, + { "enabled": false, "name": "disabled", "value": "won't show", "file": "" }, + ]), + ); + + let (result, content_type) = build_multipart_body(&body, &vec![]).await?; + assert!(content_type.is_some()); + + match result { + Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => { + // Read the entire stream to verify content + let mut buf = Vec::new(); + use tokio::io::AsyncReadExt; + stream.read_to_end(&mut buf).await.expect("Failed to read stream"); + let body_str = String::from_utf8_lossy(&buf); + assert_eq!( + body_str, + "--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\nvalue2\r\n--------YaakFormBoundary--\r\n", + ); + assert_eq!(content_length, Some(body_str.len())); + } + _ => panic!("Expected Some(SendableBody::Stream)"), + } + + assert_eq!( + content_type.unwrap(), + format!("multipart/form-data; boundary={}", MULTIPART_BOUNDARY) + ); + + Ok(()) + } + + #[tokio::test] + async fn test_multipart_body_with_file() -> Result<()> { + let mut body = BTreeMap::new(); + body.insert( + "form".to_string(), + json!([ + { "enabled": true, "name": "file_field", "file": "./tests/test.txt", "filename": "custom.txt", "contentType": "text/plain" }, + ]), + ); + + let (result, content_type) = build_multipart_body(&body, &vec![]).await?; + assert!(content_type.is_some()); + + match result { + Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => { + // Read the entire stream to verify content + let mut buf = Vec::new(); + use tokio::io::AsyncReadExt; + stream.read_to_end(&mut buf).await.expect("Failed to read stream"); + let body_str = String::from_utf8_lossy(&buf); + assert_eq!( + body_str, + "--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"file_field\"; filename=\"custom.txt\"\r\nContent-Type: text/plain\r\n\r\nThis is a test file!\n\r\n--------YaakFormBoundary--\r\n" + ); + assert_eq!(content_length, Some(body_str.len())); + } + _ => panic!("Expected Some(SendableBody::Stream)"), + } + + assert_eq!( + content_type.unwrap(), + format!("multipart/form-data; boundary={}", MULTIPART_BOUNDARY) + ); + + Ok(()) + } + + #[tokio::test] + async fn test_multipart_body_empty() -> Result<()> { + let body = BTreeMap::new(); + let (result, content_type) = build_multipart_body(&body, &vec![]).await?; + assert!(result.is_none()); + assert_eq!(content_type, None); + Ok(()) + } + + #[test] + fn test_extract_boundary_from_headers_with_custom_boundary() { + let headers = vec![( + "Content-Type".to_string(), + "multipart/form-data; boundary=customBoundary123".to_string(), + )]; + let boundary = extract_boundary_from_headers(&headers); + assert_eq!(boundary, "customBoundary123"); + } + + #[test] + fn test_extract_boundary_from_headers_default() { + let headers = vec![("Accept".to_string(), "*/*".to_string())]; + let boundary = extract_boundary_from_headers(&headers); + assert_eq!(boundary, MULTIPART_BOUNDARY); + } + + #[test] + fn test_extract_boundary_from_headers_no_boundary_in_content_type() { + let headers = vec![("Content-Type".to_string(), "multipart/form-data".to_string())]; + let boundary = extract_boundary_from_headers(&headers); + assert_eq!(boundary, MULTIPART_BOUNDARY); + } + + #[test] + fn test_extract_boundary_case_insensitive() { + let headers = vec![( + "Content-Type".to_string(), + "multipart/form-data; boundary=myBoundary".to_string(), + )]; + let boundary = extract_boundary_from_headers(&headers); + assert_eq!(boundary, "myBoundary"); + } + + #[tokio::test] + async fn test_no_content_length_with_chunked_encoding() -> Result<()> { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("Hello, World!")); + + // Headers with Transfer-Encoding: chunked + let headers = vec![("Transfer-Encoding".to_string(), "chunked".to_string())]; + + let (_, result_headers) = + build_body("POST", &Some("text/plain".to_string()), &body, headers).await?; + + // Verify that Content-Length is NOT present when Transfer-Encoding: chunked is set + let has_content_length = + result_headers.iter().any(|h| h.0.to_lowercase() == "content-length"); + assert!(!has_content_length, "Content-Length should not be present with chunked encoding"); + + // Verify that the Transfer-Encoding header is still present + let has_chunked = result_headers.iter().any(|h| { + h.0.to_lowercase() == "transfer-encoding" && h.1.to_lowercase().contains("chunked") + }); + assert!(has_chunked, "Transfer-Encoding: chunked should be preserved"); + + Ok(()) + } + + #[tokio::test] + async fn test_content_length_without_chunked_encoding() -> Result<()> { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("Hello, World!")); + + // Headers without Transfer-Encoding: chunked + let headers = vec![]; + + let (_, result_headers) = + build_body("POST", &Some("text/plain".to_string()), &body, headers).await?; + + // Verify that Content-Length IS present when Transfer-Encoding: chunked is NOT set + let content_length_header = + result_headers.iter().find(|h| h.0.to_lowercase() == "content-length"); + assert!( + content_length_header.is_some(), + "Content-Length should be present without chunked encoding" + ); + assert_eq!( + content_length_header.unwrap().1, + "13", + "Content-Length should match the body size" + ); + + Ok(()) + } +} diff --git a/src-tauri/yaak-http/tests/test.txt b/src-tauri/yaak-http/tests/test.txt new file mode 100644 index 00000000..c66d471e --- /dev/null +++ b/src-tauri/yaak-http/tests/test.txt @@ -0,0 +1 @@ +This is a test file! diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index ce16e13a..055e5e35 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -38,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-tauri/yaak-models/migrations/20251219074602_default-workspace-headers.sql b/src-tauri/yaak-models/migrations/20251219074602_default-workspace-headers.sql new file mode 100644 index 00000000..8793b2a5 --- /dev/null +++ b/src-tauri/yaak-models/migrations/20251219074602_default-workspace-headers.sql @@ -0,0 +1,15 @@ +-- Add default User-Agent header to workspaces that don't already have one (case-insensitive check) +UPDATE workspaces +SET headers = json_insert(headers, '$[#]', json('{"enabled":true,"name":"User-Agent","value":"yaak"}')) +WHERE NOT EXISTS ( + SELECT 1 FROM json_each(workspaces.headers) + WHERE LOWER(json_extract(value, '$.name')) = 'user-agent' +); + +-- Add default Accept header to workspaces that don't already have one (case-insensitive check) +UPDATE workspaces +SET headers = json_insert(headers, '$[#]', json('{"enabled":true,"name":"Accept","value":"*/*"}')) +WHERE NOT EXISTS ( + SELECT 1 FROM json_each(workspaces.headers) + WHERE LOWER(json_extract(value, '$.name')) = 'accept' +); diff --git a/src-tauri/yaak-models/migrations/20251220000000_response-request-headers.sql b/src-tauri/yaak-models/migrations/20251220000000_response-request-headers.sql new file mode 100644 index 00000000..36e73aeb --- /dev/null +++ b/src-tauri/yaak-models/migrations/20251220000000_response-request-headers.sql @@ -0,0 +1,3 @@ +-- Add request_headers and content_length_compressed columns to http_responses table +ALTER TABLE http_responses ADD COLUMN request_headers TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE http_responses ADD COLUMN content_length_compressed INTEGER; diff --git a/src-tauri/yaak-models/src/error.rs b/src-tauri/yaak-models/src/error.rs index 466e75b6..f92785a1 100644 --- a/src-tauri/yaak-models/src/error.rs +++ b/src-tauri/yaak-models/src/error.rs @@ -18,7 +18,7 @@ pub enum Error { #[error("Model serialization error: {0}")] ModelSerializationError(String), - #[error("Model error: {0}")] + #[error("HTTP error: {0}")] GenericError(String), #[error("DB Migration Failed: {0}")] diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index 9b773b83..f3f96dac 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -1323,11 +1323,13 @@ pub struct HttpResponse { pub body_path: Option, pub content_length: Option, + pub content_length_compressed: Option, pub elapsed: i32, pub elapsed_headers: i32, pub error: Option, pub headers: Vec, pub remote_addr: Option, + pub request_headers: Vec, pub status: i32, pub status_reason: Option, pub state: HttpResponseState, @@ -1368,11 +1370,13 @@ impl UpsertModelInfo for HttpResponse { (WorkspaceId, self.workspace_id.into()), (BodyPath, self.body_path.into()), (ContentLength, self.content_length.into()), + (ContentLengthCompressed, self.content_length_compressed.into()), (Elapsed, self.elapsed.into()), (ElapsedHeaders, self.elapsed_headers.into()), (Error, self.error.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (RemoteAddr, self.remote_addr.into()), + (RequestHeaders, serde_json::to_string(&self.request_headers)?.into()), (State, serde_json::to_value(self.state)?.as_str().into()), (Status, self.status.into()), (StatusReason, self.status_reason.into()), @@ -1386,11 +1390,13 @@ impl UpsertModelInfo for HttpResponse { HttpResponseIden::UpdatedAt, HttpResponseIden::BodyPath, HttpResponseIden::ContentLength, + HttpResponseIden::ContentLengthCompressed, HttpResponseIden::Elapsed, HttpResponseIden::ElapsedHeaders, HttpResponseIden::Error, HttpResponseIden::Headers, HttpResponseIden::RemoteAddr, + HttpResponseIden::RequestHeaders, HttpResponseIden::State, HttpResponseIden::Status, HttpResponseIden::StatusReason, @@ -1415,6 +1421,7 @@ impl UpsertModelInfo for HttpResponse { error: r.get("error")?, url: r.get("url")?, content_length: r.get("content_length")?, + content_length_compressed: r.get("content_length_compressed").unwrap_or_default(), version: r.get("version")?, elapsed: r.get("elapsed")?, elapsed_headers: r.get("elapsed_headers")?, @@ -1424,6 +1431,10 @@ impl UpsertModelInfo for HttpResponse { state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), body_path: r.get("body_path")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), + request_headers: serde_json::from_str( + r.get::<_, String>("request_headers").unwrap_or_default().as_str(), + ) + .unwrap_or_default(), }) } } diff --git a/src-tauri/yaak-plugins/bindings/gen_models.ts b/src-tauri/yaak-plugins/bindings/gen_models.ts index 6b2eb5c8..ebe00460 100644 --- a/src-tauri/yaak-plugins/bindings/gen_models.ts +++ b/src-tauri/yaak-plugins/bindings/gen_models.ts @@ -12,7 +12,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-tauri/yaak-templates/src/renderer.rs b/src-tauri/yaak-templates/src/renderer.rs index 73ccee55..495e1120 100644 --- a/src-tauri/yaak-templates/src/renderer.rs +++ b/src-tauri/yaak-templates/src/renderer.rs @@ -77,6 +77,12 @@ pub struct RenderOptions { pub error_behavior: RenderErrorBehavior, } +impl RenderOptions { + pub fn throw() -> Self { + Self { error_behavior: RenderErrorBehavior::Throw } + } +} + impl RenderErrorBehavior { pub fn handle(&self, r: Result) -> Result { match (self, r) { diff --git a/src-tauri/yaak-ws/src/commands.rs b/src-tauri/yaak-ws/src/commands.rs index a61ad5ea..be1073af 100644 --- a/src-tauri/yaak-ws/src/commands.rs +++ b/src-tauri/yaak-ws/src/commands.rs @@ -216,7 +216,7 @@ pub(crate) async fn connect( &UpdateSource::from_window(&window), )?; - let (mut url, url_parameters) = apply_path_placeholders(&request.url, request.url_parameters); + let (mut url, url_parameters) = apply_path_placeholders(&request.url, &request.url_parameters); if !url.starts_with("ws://") && !url.starts_with("wss://") { url.insert_str(0, "ws://"); } diff --git a/src-web/components/ExportDataDialog.tsx b/src-web/components/ExportDataDialog.tsx index 0c7c1ec7..89942fed 100644 --- a/src-web/components/ExportDataDialog.tsx +++ b/src-web/components/ExportDataDialog.tsx @@ -128,7 +128,7 @@ function ExportDataDialogContent({ ))} - + - + ) : ( diff --git a/src-web/components/HeadersEditor.tsx b/src-web/components/HeadersEditor.tsx index 7e6d76a6..47f036e1 100644 --- a/src-web/components/HeadersEditor.tsx +++ b/src-web/components/HeadersEditor.tsx @@ -34,11 +34,11 @@ export function HeadersEditor({ const validInheritedHeaders = inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? []; return ( -
+
{validInheritedHeaders.length > 0 ? ( Inherited diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index e29bda0e..86c68ee4 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -76,7 +76,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { label: 'Headers', rightSlot: ( h.name && h.value).length ?? 0} + count2={activeResponse?.headers.length ?? 0} + count={activeResponse?.requestHeaders.length ?? 0} /> ), }, @@ -85,7 +86,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { label: 'Info', }, ], - [activeResponse?.headers, mimeType, setViewMode, viewMode], + [ + activeResponse?.headers, + mimeType, + setViewMode, + viewMode, + activeResponse?.requestHeaders.length, + ], ); const activeTab = activeTabs?.[activeRequestId]; const setActiveTab = useCallback( @@ -133,7 +140,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { - +
- {activeResponse?.error ? ( - - {activeResponse.error} - - ) : ( - - - - - - {activeResponse.state === 'initialized' ? ( - - - - - Sending Request - - - - - ) : activeResponse.state === 'closed' && - activeResponse.contentLength === 0 ? ( - Empty - ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( - - ) : mimeType?.match(/^image\/svg/) ? ( - - ) : mimeType?.match(/^image/i) ? ( - - ) : mimeType?.match(/^audio/i) ? ( - - ) : mimeType?.match(/^video/i) ? ( - - ) : mimeType?.match(/pdf/i) ? ( - - ) : mimeType?.match(/csv|tab-separated/i) ? ( - - ) : ( - - )} - - - - - - - - - - - - )} +
+ {activeResponse?.error && ( + + {activeResponse.error} + + )} + {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} + {(activeResponse?.headers.length > 0 || + activeResponse?.bodyPath || + !activeResponse?.error) && ( + + + + + + {activeResponse.state === 'initialized' ? ( + + + + + Sending Request + + + + + ) : activeResponse.state === 'closed' && + activeResponse.contentLength === 0 ? ( + Empty + ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( + + ) : mimeType?.match(/^image\/svg/) ? ( + + ) : mimeType?.match(/^image/i) ? ( + + ) : mimeType?.match(/^audio/i) ? ( + + ) : mimeType?.match(/^video/i) ? ( + + ) : mimeType?.match(/pdf/i) ? ( + + ) : mimeType?.match(/csv|tab-separated/i) ? ( + + ) : ( + + )} + + + + + + + + + + + + )} +
)}
diff --git a/src-web/components/ResponseHeaders.tsx b/src-web/components/ResponseHeaders.tsx index 04bffc07..d4ef9310 100644 --- a/src-web/components/ResponseHeaders.tsx +++ b/src-web/components/ResponseHeaders.tsx @@ -1,5 +1,7 @@ import type { HttpResponse } from '@yaakapp-internal/models'; import { useMemo } from 'react'; +import { CountBadge } from './core/CountBadge'; +import { DetailsBanner } from './core/DetailsBanner'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; interface Props { @@ -7,20 +9,57 @@ interface Props { } export function ResponseHeaders({ response }: Props) { - const sortedHeaders = useMemo( - () => [...response.headers].sort((a, b) => a.name.localeCompare(b.name)), + const responseHeaders = useMemo( + () => + [...response.headers].sort((a, b) => + a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), + ), [response.headers], ); + const requestHeaders = useMemo( + () => + [...response.requestHeaders].sort((a, b) => + a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), + ), + [response.requestHeaders], + ); return ( -
- - {sortedHeaders.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: none - - {h.value} - - ))} - +
+ + Response + + } + > + + {responseHeaders.map((h, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none + + {h.value} + + ))} + + + + Request + + } + > + + {requestHeaders.map((h, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none + + {h.value} + + ))} + +
); } diff --git a/src-web/components/Settings/SettingsCertificates.tsx b/src-web/components/Settings/SettingsCertificates.tsx index d7f069ea..e6081e72 100644 --- a/src-web/components/Settings/SettingsCertificates.tsx +++ b/src-web/components/Settings/SettingsCertificates.tsx @@ -53,7 +53,7 @@ function CertificateEditor({ certificate, index, onUpdate, onRemove }: Certifica return ( diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx index c7fa3097..3d08249a 100644 --- a/src-web/components/core/Button.tsx +++ b/src-web/components/core/Button.tsx @@ -61,7 +61,6 @@ export const Button = forwardRef(function Button 'x-theme-button', `x-theme-button--${variant}`, `x-theme-button--${variant}--${color}`, - 'text-text', 'border', // They all have borders to ensure the same width 'max-w-full min-w-0', // Help with truncation 'hocus:opacity-100', // Force opacity for certain hover effects @@ -81,7 +80,7 @@ export const Button = forwardRef(function Button variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus', variant === 'solid' && color !== 'custom' && - 'enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle', + 'text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle', variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface', // Borders diff --git a/src-web/components/core/CountBadge.tsx b/src-web/components/core/CountBadge.tsx index a719c275..86c3515d 100644 --- a/src-web/components/core/CountBadge.tsx +++ b/src-web/components/core/CountBadge.tsx @@ -3,11 +3,12 @@ import classNames from 'classnames'; interface Props { count: number | true; + count2?: number | true; className?: string; color?: Color; } -export function CountBadge({ count, className, color }: Props) { +export function CountBadge({ count, count2, className, color }: Props) { if (count === 0) return null; return (
+ / + {count2 === true ? ( +
+ ) : ( + count2 + )} + + )}
); } diff --git a/src-web/components/core/DetailsBanner.tsx b/src-web/components/core/DetailsBanner.tsx index 32368c32..df0f3c4c 100644 --- a/src-web/components/core/DetailsBanner.tsx +++ b/src-web/components/core/DetailsBanner.tsx @@ -1,19 +1,48 @@ import classNames from 'classnames'; +import { atom, useAtom } from 'jotai'; import type { HTMLAttributes, ReactNode } from 'react'; +import { useMemo } from 'react'; +import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage'; import type { BannerProps } from './Banner'; import { Banner } from './Banner'; interface Props extends HTMLAttributes { summary: ReactNode; color?: BannerProps['color']; - open?: boolean; + defaultOpen?: boolean; + storageKey?: string; } -export function DetailsBanner({ className, color, summary, children, ...extraProps }: Props) { +export function DetailsBanner({ + className, + color, + summary, + children, + defaultOpen, + storageKey, + ...extraProps +}: Props) { + // biome-ignore lint/correctness/useExhaustiveDependencies: We only want to recompute the atom when storageKey changes + const openAtom = useMemo( + () => + storageKey + ? atomWithKVStorage(['details_banner', storageKey], defaultOpen ?? false) + : atom(defaultOpen ?? false), + [storageKey], + ); + + const [isOpen, setIsOpen] = useAtom(openAtom); + + const handleToggle = (e: React.SyntheticEvent) => { + if (storageKey) { + setIsOpen(e.currentTarget.open); + } + }; + return ( -
- +
+
+ {formatSize(contentLength)} ); diff --git a/src-web/lib/data/encodings.ts b/src-web/lib/data/encodings.ts index 0c18fb7d..804aa496 100644 --- a/src-web/lib/data/encodings.ts +++ b/src-web/lib/data/encodings.ts @@ -1 +1 @@ -export const encodings = ['*', 'gzip', 'compress', 'deflate', 'br', 'identity']; +export const encodings = ['*', 'gzip', 'compress', 'deflate', 'br', 'zstd', 'identity']; From 6b52a0cbed455e74a6f82021f6da1e14aefb2548 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 20 Dec 2025 14:48:23 -0800 Subject: [PATCH 04/17] Try fix tests on Windows --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index 6438d80c..84042659 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ src-tauri/vendored/**/* linguist-generated=true src-tauri/gen/schemas/**/* linguist-generated=true + +# Ensure consistent line endings for test files that check exact content +src-tauri/yaak-http/tests/test.txt text eol=lf From 5776bab2885bdc969ca42f893dc66161a080b1e4 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 21 Dec 2025 06:24:01 -0800 Subject: [PATCH 05/17] Tweak response pane and refactor timings --- src-tauri/src/http_request.rs | 67 +++++------- src-tauri/yaak-http/src/sender.rs | 63 ++++------- src-tauri/yaak-http/src/transaction.rs | 10 +- src-web/components/HttpResponsePane.tsx | 135 +++++++++++------------- src-web/components/ResponseHeaders.tsx | 64 ++++++----- src-web/components/ResponseInfo.tsx | 4 +- src-web/components/core/CountBadge.tsx | 6 +- src-web/components/core/PairEditor.tsx | 10 +- 8 files changed, 165 insertions(+), 194 deletions(-) diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 15cf6041..f4553e78 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -256,16 +256,24 @@ async fn execute_transaction( let transaction = HttpTransaction::new(sender); let start = Instant::now(); - // Capture request headers before sending (headers will be moved) + // Capture request headers before sending let request_headers: Vec = sendable_request .headers .iter() .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(); + { + // Update response with headers info and mark as connected + let mut r = response.lock().await; + r.url = sendable_request.url.clone(); + r.request_headers = request_headers.clone(); + app_handle.db().update_http_response_if_id(&r, &update_source)?; + } + // Execute the transaction with cancellation support // This returns the response with headers, but body is not yet consumed - let (http_response, _events) = + let (mut http_response, _events) = transaction.execute_with_cancellation(sendable_request, cancelled_rx.clone()).await?; // Prepare the response path before consuming the body @@ -280,37 +288,30 @@ async fn execute_transaction( }; // Extract metadata before consuming the body (headers are available immediately) - let status = http_response.status; - let status_reason = http_response.status_reason.clone(); - let url = http_response.url.clone(); - let remote_addr = http_response.remote_addr.clone(); - let version = http_response.version.clone(); - let content_length = http_response.content_length; + // Url might change, so update again let headers: Vec = http_response .headers .iter() .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(); - let headers_timing = http_response.timing.headers; - // Update response with headers info and mark as connected { + // Update response with headers info and mark as connected let mut r = response.lock().await; - r.body_path = Some( - body_path - .to_str() - .ok_or(GenericError(format!("Invalid path {body_path:?}")))? - .to_string(), - ); - r.elapsed_headers = headers_timing.as_millis() as i32; - r.elapsed = start.elapsed().as_millis() as i32; - r.status = status as i32; - r.status_reason = status_reason.clone(); - r.url = url.clone(); - r.remote_addr = remote_addr.clone(); - r.version = version.clone(); + r.body_path = Some(body_path.to_string_lossy().to_string()); + r.elapsed_headers = start.elapsed().as_millis() as i32; + r.status = http_response.status as i32; + r.status_reason = http_response.status_reason.clone().clone(); + r.url = http_response.url.clone().clone(); + r.remote_addr = http_response.remote_addr.clone(); + r.version = http_response.version.clone().clone(); r.headers = headers.clone(); - r.request_headers = request_headers.clone(); + r.content_length = http_response.content_length.map(|l| l as i32); + r.request_headers = http_response + .request_headers + .iter() + .map(|(n, v)| HttpResponseHeader { name: n.clone(), value: v.clone() }) + .collect(); r.state = HttpResponseState::Connected; app_handle.db().update_http_response_if_id(&r, &update_source)?; } @@ -332,7 +333,7 @@ async fn execute_transaction( let mut buf = [0u8; 8192]; loop { - // Check for cancellation - if we already have headers/body, just close cleanly + // Check for cancellation. If we already have headers/body, just close cleanly without error if *cancelled_rx.borrow() { break; } @@ -350,7 +351,7 @@ async fn execute_transaction( // Update response in DB with progress let mut r = response.lock().await; - r.elapsed = start.elapsed().as_millis() as i32; + r.elapsed = start.elapsed().as_millis() as i32; // Approx until the end r.content_length = Some(written_bytes as i32); app_handle.db().update_http_response_if_id(&r, &update_source)?; } @@ -362,20 +363,8 @@ async fn execute_transaction( // Final update with closed state let mut resp = response.lock().await.clone(); - resp.headers = headers; - resp.request_headers = request_headers; - resp.status = status as i32; - resp.status_reason = status_reason; - resp.url = url; - resp.remote_addr = remote_addr; - resp.version = version; - resp.state = HttpResponseState::Closed; - resp.content_length = match content_length { - Some(l) => Some(l as i32), - None => Some(written_bytes as i32), - }; resp.elapsed = start.elapsed().as_millis() as i32; - resp.elapsed_headers = headers_timing.as_millis() as i32; + resp.state = HttpResponseState::Closed; resp.body_path = Some( body_path.to_str().ok_or(GenericError(format!("Invalid path {body_path:?}",)))?.to_string(), ); diff --git a/src-tauri/yaak-http/src/sender.rs b/src-tauri/yaak-http/src/sender.rs index 236dcb45..54c9b390 100644 --- a/src-tauri/yaak-http/src/sender.rs +++ b/src-tauri/yaak-http/src/sender.rs @@ -7,16 +7,10 @@ use reqwest::{Client, Method, Version}; use std::collections::HashMap; use std::fmt::Display; use std::pin::Pin; -use std::time::{Duration, Instant}; +use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt, BufReader}; use tokio_util::io::StreamReader; -#[derive(Debug, Default, Clone)] -pub struct HttpResponseTiming { - pub headers: Duration, - pub body: Duration, -} - #[derive(Debug)] pub enum HttpResponseEvent { Setting(String, String), @@ -68,6 +62,8 @@ pub struct HttpResponse { pub status_reason: Option, /// Response headers pub headers: HashMap, + /// Request headers + pub request_headers: HashMap, /// Content-Length from headers (may differ from actual body size) pub content_length: Option, /// Final URL (after redirects) @@ -76,15 +72,11 @@ pub struct HttpResponse { pub remote_addr: Option, /// HTTP version (e.g., "HTTP/1.1", "HTTP/2") pub version: Option, - /// Timing information - pub timing: HttpResponseTiming, /// The body stream (consumed when calling bytes(), text(), write_to_file(), or drain()) body_stream: Option, /// Content-Encoding for decompression encoding: ContentEncoding, - /// Start time for timing the body read - start_time: Instant, } impl std::fmt::Debug for HttpResponse { @@ -97,7 +89,6 @@ impl std::fmt::Debug for HttpResponse { .field("url", &self.url) .field("remote_addr", &self.remote_addr) .field("version", &self.version) - .field("timing", &self.timing) .field("body_stream", &"") .field("encoding", &self.encoding) .finish() @@ -111,33 +102,31 @@ impl HttpResponse { status: u16, status_reason: Option, headers: HashMap, + request_headers: HashMap, content_length: Option, url: String, remote_addr: Option, version: Option, - timing: HttpResponseTiming, body_stream: BodyStream, encoding: ContentEncoding, - start_time: Instant, ) -> Self { Self { status, status_reason, headers, + request_headers, content_length, url, remote_addr, version, - timing, body_stream: Some(body_stream), encoding, - start_time, } } /// Consume the body and return it as bytes (loads entire body into memory). /// Also decompresses the body if Content-Encoding is set. - pub async fn bytes(mut self) -> Result<(Vec, BodyStats, HttpResponseTiming)> { + pub async fn bytes(mut self) -> Result<(Vec, BodyStats)> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; @@ -163,9 +152,6 @@ impl HttpResponse { } } - let mut timing = self.timing.clone(); - timing.body = self.start_time.elapsed(); - let stats = BodyStats { // For now, we can't easily track compressed size when streaming through decoder // Use content_length as an approximation, or decompressed size if identity encoding @@ -173,21 +159,21 @@ impl HttpResponse { size_decompressed: decompressed.len() as u64, }; - Ok((decompressed, stats, timing)) + Ok((decompressed, stats)) } /// Consume the body and return it as a UTF-8 string. - pub async fn text(self) -> Result<(String, BodyStats, HttpResponseTiming)> { - let (bytes, stats, timing) = self.bytes().await?; + pub async fn text(self) -> Result<(String, BodyStats)> { + let (bytes, stats) = self.bytes().await?; let text = String::from_utf8(bytes) .map_err(|e| Error::RequestError(format!("Response is not valid UTF-8: {}", e)))?; - Ok((text, stats, timing)) + Ok((text, stats)) } /// Take the body stream for manual consumption. /// Returns an AsyncRead that decompresses on-the-fly if Content-Encoding is set. /// The caller is responsible for reading and processing the stream. - pub fn into_body_stream(mut self) -> Result> { + pub fn into_body_stream(&mut self) -> Result> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; @@ -199,7 +185,7 @@ impl HttpResponse { } /// Discard the body without reading it (useful for redirects). - pub async fn drain(mut self) -> Result { + pub async fn drain(mut self) -> Result<()> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; @@ -220,10 +206,7 @@ impl HttpResponse { } } - let mut timing = self.timing.clone(); - timing.body = self.start_time.elapsed(); - - Ok(timing) + Ok(()) } } @@ -297,9 +280,6 @@ impl HttpSender for ReqwestSender { } } - let start = Instant::now(); - let mut timing = HttpResponseTiming::default(); - // Send the request let sendable_req = req_builder.build()?; events.push(HttpResponseEvent::Setting( @@ -316,11 +296,11 @@ impl HttpSender for ReqwestSender { method: sendable_req.method().to_string(), }); + let mut request_headers = HashMap::new(); for (name, value) in sendable_req.headers() { - events.push(HttpResponseEvent::HeaderUp( - name.to_string(), - value.to_str().unwrap_or_default().to_string(), - )); + let v = value.to_str().unwrap_or_default().to_string(); + request_headers.insert(name.to_string(), v.clone()); + events.push(HttpResponseEvent::HeaderUp(name.to_string(), v)); } events.push(HttpResponseEvent::HeaderUpDone); events.push(HttpResponseEvent::Info("Sending request to server".to_string())); @@ -341,17 +321,13 @@ impl HttpSender for ReqwestSender { let url = response.url().to_string(); let remote_addr = response.remote_addr().map(|a| a.to_string()); let version = Some(version_to_str(&response.version())); + let content_length = response.content_length(); events.push(HttpResponseEvent::ReceiveUrl { version: response.version(), status: response.status().to_string(), }); - timing.headers = start.elapsed(); - - // Extract content length - let content_length = response.content_length(); - // Extract headers let mut headers = HashMap::new(); for (key, value) in response.headers() { @@ -385,14 +361,13 @@ impl HttpSender for ReqwestSender { status, status_reason, headers, + request_headers, content_length, url, remote_addr, version, - timing, body_stream, encoding, - start, )) } } diff --git a/src-tauri/yaak-http/src/transaction.rs b/src-tauri/yaak-http/src/transaction.rs index de4da72a..7c688138 100644 --- a/src-tauri/yaak-http/src/transaction.rs +++ b/src-tauri/yaak-http/src/transaction.rs @@ -201,12 +201,11 @@ impl HttpTransaction { mod tests { use super::*; use crate::decompress::ContentEncoding; - use crate::sender::{HttpResponseEvent, HttpResponseTiming, HttpSender}; + use crate::sender::{HttpResponseEvent, HttpSender}; use async_trait::async_trait; use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; - use std::time::Instant; use tokio::io::AsyncRead; use tokio::sync::Mutex; @@ -246,14 +245,13 @@ mod tests { mock.status, None, // status_reason mock.headers, + HashMap::new(), None, // content_length "https://example.com".to_string(), // url None, // remote_addr Some("HTTP/1.1".to_string()), // version - HttpResponseTiming::default(), body_stream, ContentEncoding::Identity, - Instant::now(), )) } } @@ -277,7 +275,7 @@ mod tests { assert_eq!(result.status, 200); // Consume the body to verify it - let (body, _, _) = result.bytes().await.unwrap(); + let (body, _) = result.bytes().await.unwrap(); assert_eq!(body, b"OK"); } @@ -308,7 +306,7 @@ mod tests { let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); assert_eq!(result.status, 200); - let (body, _, _) = result.bytes().await.unwrap(); + let (body, _) = result.bytes().await.unwrap(); assert_eq!(body, b"Final"); } diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index 86c68ee4..6aea7918 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -62,6 +62,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { { value: TAB_BODY, label: 'Preview Mode', + hidden: (activeResponse?.contentLength || 0) === 0, options: { value: viewMode, onChange: setViewMode, @@ -92,6 +93,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { setViewMode, viewMode, activeResponse?.requestHeaders.length, + activeResponse?.contentLength, ], ); const activeTab = activeTabs?.[activeRequestId]; @@ -163,79 +165,66 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { )} {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} - {(activeResponse?.headers.length > 0 || - activeResponse?.bodyPath || - !activeResponse?.error) && ( - - - - - - {activeResponse.state === 'initialized' ? ( - - - - - Sending Request - - - - - ) : activeResponse.state === 'closed' && - activeResponse.contentLength === 0 ? ( - Empty - ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( - - ) : mimeType?.match(/^image\/svg/) ? ( - - ) : mimeType?.match(/^image/i) ? ( - - ) : mimeType?.match(/^audio/i) ? ( - - ) : mimeType?.match(/^video/i) ? ( - - ) : mimeType?.match(/pdf/i) ? ( - - ) : mimeType?.match(/csv|tab-separated/i) ? ( - - ) : ( - - )} - - - - - - - - - - - - )} + + + + + + {activeResponse.state === 'initialized' ? ( + + + + + Sending Request + + + + + ) : activeResponse.state === 'closed' && + activeResponse.contentLength === 0 ? ( + Empty + ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( + + ) : mimeType?.match(/^image\/svg/) ? ( + + ) : mimeType?.match(/^image/i) ? ( + + ) : mimeType?.match(/^audio/i) ? ( + + ) : mimeType?.match(/^video/i) ? ( + + ) : mimeType?.match(/pdf/i) ? ( + + ) : mimeType?.match(/csv|tab-separated/i) ? ( + + ) : ( + + )} + + + + + + + + + + +
)} diff --git a/src-web/components/ResponseHeaders.tsx b/src-web/components/ResponseHeaders.tsx index d4ef9310..e109c806 100644 --- a/src-web/components/ResponseHeaders.tsx +++ b/src-web/components/ResponseHeaders.tsx @@ -25,41 +25,53 @@ export function ResponseHeaders({ response }: Props) { ); return (
+ + Request + + } + > + {requestHeaders.length === 0 ? ( + + ) : ( + + {requestHeaders.map((h, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none + + {h.value} + + ))} + + )} + - Response + Response } > - - {responseHeaders.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: none - - {h.value} - - ))} - - - - Request - - } - > - - {requestHeaders.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: none - - {h.value} - - ))} - + {responseHeaders.length === 0 ? ( + + ) : ( + + {responseHeaders.map((h, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none + + {h.value} + + ))} + + )}
); } + +function NoHeaders() { + return No Headers; +} diff --git a/src-web/components/ResponseInfo.tsx b/src-web/components/ResponseInfo.tsx index ea17ae74..dcb68816 100644 --- a/src-web/components/ResponseInfo.tsx +++ b/src-web/components/ResponseInfo.tsx @@ -12,10 +12,10 @@ export function ResponseInfo({ response }: Props) {
- {response.version} + {response.version ?? --} - {response.remoteAddr} + {response.remoteAddr ?? --} )} @@ -798,7 +798,13 @@ function FileActionsDropdown({ items={fileItems} itemsAfter={itemsAfter} > - + ); } From 7e0aa919fb07e505fbe5fa9a95b15543d2681189 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 21 Dec 2025 06:28:36 -0800 Subject: [PATCH 06/17] Immediate cancellation --- src-tauri/src/http_request.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index f4553e78..8e197d6d 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -250,7 +250,7 @@ async fn execute_transaction( response_id: &String, app_handle: &AppHandle, update_source: &UpdateSource, - cancelled_rx: Receiver, + mut cancelled_rx: Receiver, ) -> Result { let sender = ReqwestSender::with_client(client); let transaction = HttpTransaction::new(sender); @@ -338,7 +338,16 @@ async fn execute_transaction( break; } - match body_stream.read(&mut buf).await { + // Use select! to race between reading and cancellation, so cancellation is immediate + let read_result = tokio::select! { + biased; + _ = cancelled_rx.changed() => { + break; + } + result = body_stream.read(&mut buf) => result, + }; + + match read_result { Ok(0) => break, // EOF Ok(n) => { file.write_all(&buf[..n]) From 089c7e8dced388975e1fe68225fa0a1c4c28c81d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 21 Dec 2025 14:34:37 -0800 Subject: [PATCH 07/17] Http response events (#326) --- .gitignore | 1 + src-tauri/src/http_request.rs | 28 +- src-tauri/src/lib.rs | 16 +- src-tauri/yaak-http/src/sender.rs | 136 ++++++- src-tauri/yaak-http/src/transaction.rs | 74 ++-- src-tauri/yaak-models/bindings/gen_models.ts | 11 +- src-tauri/yaak-models/guest-js/atoms.ts | 1 + src-tauri/yaak-models/guest-js/util.ts | 1 + .../20251221000000_http-response-events.sql | 15 + src-tauri/yaak-models/src/models.rs | 138 +++++++ src-tauri/yaak-sync/src/models.rs | 1 + src-web/components/HttpResponsePane.tsx | 16 +- src-web/components/ResponseEvents.tsx | 341 ++++++++++++++++++ src-web/components/core/HttpMethodTag.tsx | 5 +- src-web/components/core/HttpStatusTag.tsx | 32 +- src-web/hooks/useHttpResponseEvents.ts | 28 ++ src-web/lib/contentType.ts | 8 +- src-web/lib/tauri.ts | 1 + 18 files changed, 779 insertions(+), 74 deletions(-) create mode 100644 src-tauri/yaak-models/migrations/20251221000000_http-response-events.sql create mode 100644 src-web/components/ResponseEvents.tsx create mode 100644 src-web/hooks/useHttpResponseEvents.ts diff --git a/.gitignore b/.gitignore index a0a25619..e4995499 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out .tmp tmp .zed +codebook.toml diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 8e197d6d..cc4ad156 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -19,8 +19,8 @@ use yaak_http::sender::ReqwestSender; use yaak_http::transaction::HttpTransaction; use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params}; use yaak_models::models::{ - CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpResponseState, - ProxySetting, ProxySettingAuth, + CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, + HttpResponseState, ProxySetting, ProxySettingAuth, }; use yaak_models::query_manager::QueryManagerExt; use yaak_models::util::UpdateSource; @@ -271,10 +271,30 @@ async fn execute_transaction( app_handle.db().update_http_response_if_id(&r, &update_source)?; } + // Create channel for receiving events and spawn a task to store them in DB + let (event_tx, mut event_rx) = + tokio::sync::mpsc::unbounded_channel::(); + + // Write events to DB in a task + { + let response_id = response_id.clone(); + let workspace_id = response.lock().await.workspace_id.clone(); + let app_handle = app_handle.clone(); + let update_source = update_source.clone(); + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into()); + let _ = app_handle.db().upsert(&db_event, &update_source); + } + }); + }; + // Execute the transaction with cancellation support // This returns the response with headers, but body is not yet consumed - let (mut http_response, _events) = - transaction.execute_with_cancellation(sendable_request, cancelled_rx.clone()).await?; + // Events (headers, settings, chunks) are sent through the channel + let mut http_response = transaction + .execute_with_cancellation(sendable_request, cancelled_rx.clone(), event_tx) + .await?; // Prepare the response path before consuming the body let dir = app_handle.path().app_data_dir()?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49e5f773..b1f77b4c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,8 +34,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message}; use yaak_mac_window::AppHandleMacWindowExt; use yaak_models::models::{ AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, - GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, Workspace, - WorkspaceMeta, + GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, + Plugin, Workspace, WorkspaceMeta, }; use yaak_models::query_manager::QueryManagerExt; use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources}; @@ -830,6 +830,17 @@ async fn cmd_get_sse_events(file_path: &str) -> YaakResult> Ok(events) } +#[tauri::command] +async fn cmd_get_http_response_events( + app_handle: AppHandle, + response_id: &str, +) -> YaakResult> { + use yaak_models::models::HttpResponseEventIden; + let events: Vec = + app_handle.db().find_many(HttpResponseEventIden::ResponseId, response_id, None)?; + Ok(events) +} + #[tauri::command] async fn cmd_import_data( window: WebviewWindow, @@ -1462,6 +1473,7 @@ pub fn run() { cmd_get_http_authentication_summaries, cmd_get_http_authentication_config, cmd_get_sse_events, + cmd_get_http_response_events, cmd_get_workspace_meta, cmd_grpc_go, cmd_grpc_reflect, diff --git a/src-tauri/yaak-http/src/sender.rs b/src-tauri/yaak-http/src/sender.rs index 54c9b390..d6cb88ea 100644 --- a/src-tauri/yaak-http/src/sender.rs +++ b/src-tauri/yaak-http/src/sender.rs @@ -7,20 +7,45 @@ use reqwest::{Client, Method, Version}; use std::collections::HashMap; use std::fmt::Display; use std::pin::Pin; +use std::task::{Context, Poll}; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncReadExt, BufReader}; +use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf}; +use tokio::sync::mpsc; use tokio_util::io::StreamReader; -#[derive(Debug)] +#[derive(Debug, Clone)] +pub enum RedirectBehavior { + /// 307/308: Method and body are preserved + Preserve, + /// 303 or 301/302 with POST: Method changed to GET, body dropped + DropBody, +} + +#[derive(Debug, Clone)] pub enum HttpResponseEvent { Setting(String, String), Info(String), - SendUrl { method: String, path: String }, - ReceiveUrl { version: Version, status: String }, + Redirect { + url: String, + status: u16, + behavior: RedirectBehavior, + }, + SendUrl { + method: String, + path: String, + }, + ReceiveUrl { + version: Version, + status: String, + }, HeaderUp(String, String), HeaderDown(String, String), - HeaderUpDone, - HeaderDownDone, + ChunkSent { + bytes: usize, + }, + ChunkReceived { + bytes: usize, + }, } impl Display for HttpResponseEvent { @@ -28,14 +53,47 @@ impl Display for HttpResponseEvent { match self { HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value), HttpResponseEvent::Info(s) => write!(f, "* {}", s), + HttpResponseEvent::Redirect { url, status, behavior } => { + let behavior_str = match behavior { + RedirectBehavior::Preserve => "preserve", + RedirectBehavior::DropBody => "drop body", + }; + write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str) + } HttpResponseEvent::SendUrl { method, path } => write!(f, "> {} {}", method, path), HttpResponseEvent::ReceiveUrl { version, status } => { write!(f, "< {} {}", version_to_str(version), status) } HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value), - HttpResponseEvent::HeaderUpDone => write!(f, ">"), HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value), - HttpResponseEvent::HeaderDownDone => write!(f, "<"), + HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes), + HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes), + } + } +} + +impl From for yaak_models::models::HttpResponseEventData { + fn from(event: HttpResponseEvent) -> Self { + use yaak_models::models::HttpResponseEventData as D; + match event { + HttpResponseEvent::Setting(name, value) => D::Setting { name, value }, + HttpResponseEvent::Info(message) => D::Info { message }, + HttpResponseEvent::Redirect { url, status, behavior } => D::Redirect { + url, + status, + behavior: match behavior { + RedirectBehavior::Preserve => "preserve".to_string(), + RedirectBehavior::DropBody => "drop_body".to_string(), + }, + }, + HttpResponseEvent::SendUrl { method, path } => D::SendUrl { method, path }, + HttpResponseEvent::ReceiveUrl { version, status } => { + D::ReceiveUrl { version: format!("{:?}", version), status } + } + HttpResponseEvent::HeaderUp(name, value) => D::HeaderUp { name, value }, + HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value }, + HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes }, + HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes }, } } } @@ -49,6 +107,40 @@ pub struct BodyStats { pub size_decompressed: u64, } +/// An AsyncRead wrapper that sends chunk events as data is read +pub struct TrackingRead { + inner: R, + event_tx: mpsc::UnboundedSender, + ended: bool, +} + +impl TrackingRead { + pub fn new(inner: R, event_tx: mpsc::UnboundedSender) -> Self { + Self { inner, event_tx, ended: false } + } +} + +impl AsyncRead for TrackingRead { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + let result = Pin::new(&mut self.inner).poll_read(cx, buf); + if let Poll::Ready(Ok(())) = &result { + let bytes_read = buf.filled().len() - before; + if bytes_read > 0 { + // Ignore send errors - receiver may have been dropped + let _ = self.event_tx.send(HttpResponseEvent::ChunkReceived { bytes: bytes_read }); + } else if !self.ended { + self.ended = true; + } + } + result + } +} + /// Type alias for the body stream type BodyStream = Pin>; @@ -215,10 +307,11 @@ impl HttpResponse { pub trait HttpSender: Send + Sync { /// Send an HTTP request and return the response with headers. /// The body is not consumed until you call bytes(), text(), write_to_file(), or drain(). + /// Events are sent through the provided channel. async fn send( &self, request: SendableHttpRequest, - events: &mut Vec, + event_tx: mpsc::UnboundedSender, ) -> Result; } @@ -245,8 +338,13 @@ impl HttpSender for ReqwestSender { async fn send( &self, request: SendableHttpRequest, - events: &mut Vec, + event_tx: mpsc::UnboundedSender, ) -> Result { + // Helper to send events (ignores errors if receiver is dropped) + let send_event = |event: HttpResponseEvent| { + let _ = event_tx.send(event); + }; + // Parse the HTTP method let method = Method::from_bytes(request.method.as_bytes()) .map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?; @@ -282,7 +380,7 @@ impl HttpSender for ReqwestSender { // Send the request let sendable_req = req_builder.build()?; - events.push(HttpResponseEvent::Setting( + send_event(HttpResponseEvent::Setting( "timeout".to_string(), if request.options.timeout.unwrap_or_default().is_zero() { "Infinity".to_string() @@ -291,7 +389,7 @@ impl HttpSender for ReqwestSender { }, )); - events.push(HttpResponseEvent::SendUrl { + send_event(HttpResponseEvent::SendUrl { path: sendable_req.url().path().to_string(), method: sendable_req.method().to_string(), }); @@ -300,10 +398,9 @@ impl HttpSender for ReqwestSender { for (name, value) in sendable_req.headers() { let v = value.to_str().unwrap_or_default().to_string(); request_headers.insert(name.to_string(), v.clone()); - events.push(HttpResponseEvent::HeaderUp(name.to_string(), v)); + send_event(HttpResponseEvent::HeaderUp(name.to_string(), v)); } - events.push(HttpResponseEvent::HeaderUpDone); - events.push(HttpResponseEvent::Info("Sending request to server".to_string())); + send_event(HttpResponseEvent::Info("Sending request to server".to_string())); // Map some errors to our own, so they look nicer let response = self.client.execute(sendable_req).await.map_err(|e| { @@ -323,7 +420,7 @@ impl HttpSender for ReqwestSender { let version = Some(version_to_str(&response.version())); let content_length = response.content_length(); - events.push(HttpResponseEvent::ReceiveUrl { + send_event(HttpResponseEvent::ReceiveUrl { version: response.version(), status: response.status().to_string(), }); @@ -332,11 +429,10 @@ impl HttpSender for ReqwestSender { let mut headers = HashMap::new(); for (key, value) in response.headers() { if let Ok(v) = value.to_str() { - events.push(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string())); + send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string())); headers.insert(key.to_string(), v.to_string()); } } - events.push(HttpResponseEvent::HeaderDownDone); // Determine content encoding for decompression // HTTP headers are case-insensitive, so we need to search for any casing @@ -355,7 +451,9 @@ impl HttpSender for ReqwestSender { byte_stream.map(|result| result.map_err(|e| std::io::Error::other(e))), ); - let body_stream: BodyStream = Box::pin(stream_reader); + // Wrap the stream with tracking to emit chunk received events via the same channel + let tracking_reader = TrackingRead::new(stream_reader, event_tx); + let body_stream: BodyStream = Box::pin(tracking_reader); Ok(HttpResponse::new( status, diff --git a/src-tauri/yaak-http/src/transaction.rs b/src-tauri/yaak-http/src/transaction.rs index 7c688138..d779e9f5 100644 --- a/src-tauri/yaak-http/src/transaction.rs +++ b/src-tauri/yaak-http/src/transaction.rs @@ -1,6 +1,7 @@ use crate::error::Result; -use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender}; +use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior}; use crate::types::SendableHttpRequest; +use tokio::sync::mpsc; use tokio::sync::watch::Receiver; /// HTTP Transaction that manages the lifecycle of a request, including redirect handling @@ -22,17 +23,23 @@ impl HttpTransaction { /// Execute the request with cancellation support. /// Returns an HttpResponse with unconsumed body - caller decides how to consume it. + /// Events are sent through the provided channel. pub async fn execute_with_cancellation( &self, request: SendableHttpRequest, mut cancelled_rx: Receiver, - ) -> Result<(HttpResponse, Vec)> { + event_tx: mpsc::UnboundedSender, + ) -> Result { let mut redirect_count = 0; let mut current_url = request.url; let mut current_method = request.method; let mut current_headers = request.headers; let mut current_body = request.body; - let mut events = Vec::new(); + + // Helper to send events (ignores errors if receiver is dropped) + let send_event = |event: HttpResponseEvent| { + let _ = event_tx.send(event); + }; loop { // Check for cancellation before each request @@ -50,14 +57,14 @@ impl HttpTransaction { }; // Send the request - events.push(HttpResponseEvent::Setting( + send_event(HttpResponseEvent::Setting( "redirects".to_string(), request.options.follow_redirects.to_string(), )); // Execute with cancellation support let response = tokio::select! { - result = self.sender.send(req, &mut events) => result?, + result = self.sender.send(req, event_tx.clone()) => result?, _ = cancelled_rx.changed() => { return Err(crate::error::Error::RequestCanceledError); } @@ -65,12 +72,12 @@ impl HttpTransaction { if !Self::is_redirect(response.status) { // Not a redirect - return the response for caller to consume body - return Ok((response, events)); + return Ok(response); } if !request.options.follow_redirects { // Redirects disabled - return the redirect response as-is - return Ok((response, events)); + return Ok(response); } // Check if we've exceeded max redirects @@ -99,7 +106,7 @@ impl HttpTransaction { // Also get status before draining let status = response.status; - events.push(HttpResponseEvent::Info("Ignoring the response body".to_string())); + send_event(HttpResponseEvent::Info("Ignoring the response body".to_string())); // Drain the redirect response body before following response.drain().await?; @@ -118,38 +125,36 @@ impl HttpTransaction { format!("{}/{}", base_path, location) }; - events.push(HttpResponseEvent::Info(format!( - "Issuing redirect {} to: {}", - redirect_count + 1, - current_url - ))); + // Determine redirect behavior based on status code and method + let behavior = if status == 303 { + // 303 See Other always changes to GET + RedirectBehavior::DropBody + } else if (status == 301 || status == 302) && current_method == "POST" { + // For 301/302, change POST to GET (common browser behavior) + RedirectBehavior::DropBody + } else { + // For 307 and 308, the method and body are preserved + // Also for 301/302 with non-POST methods + RedirectBehavior::Preserve + }; + + send_event(HttpResponseEvent::Redirect { + url: current_url.clone(), + status, + behavior: behavior.clone(), + }); // Handle method changes for certain redirect codes - if status == 303 { - // 303 See Other always changes to GET + if matches!(behavior, RedirectBehavior::DropBody) { if current_method != "GET" { current_method = "GET".to_string(); - events.push(HttpResponseEvent::Info("Changing method to GET".to_string())); } // Remove content-related headers current_headers.retain(|h| { let name_lower = h.0.to_lowercase(); !name_lower.starts_with("content-") && name_lower != "transfer-encoding" }); - } else if status == 301 || status == 302 { - // For 301/302, change POST to GET (common browser behavior) - // but keep other methods as-is - if current_method == "POST" { - events.push(HttpResponseEvent::Info("Changing method to GET".to_string())); - current_method = "GET".to_string(); - // Remove content-related headers - current_headers.retain(|h| { - let name_lower = h.0.to_lowercase(); - !name_lower.starts_with("content-") && name_lower != "transfer-encoding" - }); - } } - // For 307 and 308, the method and body are preserved // Reset body for next iteration (since it was moved in the send call) // For redirects that change method to GET or for all redirects since body was consumed @@ -231,7 +236,7 @@ mod tests { async fn send( &self, _request: SendableHttpRequest, - _events: &mut Vec, + _event_tx: mpsc::UnboundedSender, ) -> Result { let mut responses = self.responses.lock().await; if responses.is_empty() { @@ -271,7 +276,8 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); // Consume the body to verify it @@ -303,7 +309,8 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); let (body, _) = result.bytes().await.unwrap(); @@ -334,7 +341,8 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let result = transaction.execute_with_cancellation(request, rx).await; + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let result = transaction.execute_with_cancellation(request, rx, event_tx).await; if let Err(crate::error::Error::RequestError(msg)) = result { assert!(msg.contains("Maximum redirect limit")); } else { diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index 055e5e35..f3742077 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta; +export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta; export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, }; @@ -40,6 +40,15 @@ export type HttpRequestHeader = { enabled?: boolean, name: string, value: string export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; + +/** + * Serializable representation of HTTP response events for DB storage. + * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. + * The `From` impl is in yaak-http to avoid circular dependencies. + */ +export type HttpResponseEventData = { "type": "start_request" } | { "type": "end_request" } | { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; + export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseState = "initialized" | "connected" | "closed"; diff --git a/src-tauri/yaak-models/guest-js/atoms.ts b/src-tauri/yaak-models/guest-js/atoms.ts index fc12373d..da3ad730 100644 --- a/src-tauri/yaak-models/guest-js/atoms.ts +++ b/src-tauri/yaak-models/guest-js/atoms.ts @@ -15,6 +15,7 @@ export const grpcEventsAtom = createOrderedModelAtom('grpc_event', 'createdAt', export const grpcRequestsAtom = createModelAtom('grpc_request'); export const httpRequestsAtom = createModelAtom('http_request'); export const httpResponsesAtom = createOrderedModelAtom('http_response', 'createdAt', 'desc'); +export const httpResponseEventsAtom = createOrderedModelAtom('http_response_event', 'createdAt', 'asc'); export const keyValuesAtom = createModelAtom('key_value'); export const pluginsAtom = createModelAtom('plugin'); export const settingsAtom = createSingularModelAtom('settings'); diff --git a/src-tauri/yaak-models/guest-js/util.ts b/src-tauri/yaak-models/guest-js/util.ts index 412fa98b..81981114 100644 --- a/src-tauri/yaak-models/guest-js/util.ts +++ b/src-tauri/yaak-models/guest-js/util.ts @@ -11,6 +11,7 @@ export function newStoreData(): ModelStoreData { grpc_request: {}, http_request: {}, http_response: {}, + http_response_event: {}, key_value: {}, plugin: {}, settings: {}, diff --git a/src-tauri/yaak-models/migrations/20251221000000_http-response-events.sql b/src-tauri/yaak-models/migrations/20251221000000_http-response-events.sql new file mode 100644 index 00000000..ad9f0fa1 --- /dev/null +++ b/src-tauri/yaak-models/migrations/20251221000000_http-response-events.sql @@ -0,0 +1,15 @@ +CREATE TABLE http_response_events +( + id TEXT NOT NULL + PRIMARY KEY, + model TEXT DEFAULT 'http_response_event' NOT NULL, + workspace_id TEXT NOT NULL + REFERENCES workspaces + ON DELETE CASCADE, + response_id TEXT NOT NULL + REFERENCES http_responses + ON DELETE CASCADE, + created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, + updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, + event TEXT NOT NULL +); diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index f3f96dac..012a40fd 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -1439,6 +1439,143 @@ impl UpsertModelInfo for HttpResponse { } } +/// Serializable representation of HTTP response events for DB storage. +/// This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. +/// The `From` impl is in yaak-http to avoid circular dependencies. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export, export_to = "gen_models.ts")] +pub enum HttpResponseEventData { + Setting { + name: String, + value: String, + }, + Info { + message: String, + }, + Redirect { + url: String, + status: u16, + behavior: String, + }, + SendUrl { + method: String, + path: String, + }, + ReceiveUrl { + version: String, + status: String, + }, + HeaderUp { + name: String, + value: String, + }, + HeaderDown { + name: String, + value: String, + }, + ChunkSent { + bytes: usize, + }, + ChunkReceived { + bytes: usize, + }, +} + +impl Default for HttpResponseEventData { + fn default() -> Self { + Self::Info { message: String::new() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +#[enum_def(table_name = "http_response_events")] +pub struct HttpResponseEvent { + #[ts(type = "\"http_response_event\"")] + pub model: String, + pub id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub workspace_id: String, + pub response_id: String, + pub event: HttpResponseEventData, +} + +impl UpsertModelInfo for HttpResponseEvent { + fn table_name() -> impl IntoTableRef + IntoIden { + HttpResponseEventIden::Table + } + + fn id_column() -> impl IntoIden + Eq + Clone { + HttpResponseEventIden::Id + } + + fn generate_id() -> String { + generate_prefixed_id("re") + } + + fn order_by() -> (impl IntoColumnRef, Order) { + (HttpResponseEventIden::CreatedAt, Order::Asc) + } + + fn get_id(&self) -> String { + self.id.clone() + } + + fn insert_values( + self, + source: &UpdateSource, + ) -> Result)>> { + use HttpResponseEventIden::*; + Ok(vec![ + (CreatedAt, upsert_date(source, self.created_at)), + (UpdatedAt, upsert_date(source, self.updated_at)), + (WorkspaceId, self.workspace_id.into()), + (ResponseId, self.response_id.into()), + (Event, serde_json::to_string(&self.event)?.into()), + ]) + } + + fn update_columns() -> Vec { + vec![ + HttpResponseEventIden::UpdatedAt, + HttpResponseEventIden::Event, + ] + } + + fn from_row(r: &Row) -> rusqlite::Result + where + Self: Sized, + { + let event: String = r.get("event")?; + Ok(Self { + id: r.get("id")?, + model: r.get("model")?, + workspace_id: r.get("workspace_id")?, + response_id: r.get("response_id")?, + created_at: r.get("created_at")?, + updated_at: r.get("updated_at")?, + event: serde_json::from_str(&event).unwrap_or_default(), + }) + } +} + +impl HttpResponseEvent { + pub fn new(response_id: &str, workspace_id: &str, event: HttpResponseEventData) -> Self { + Self { + model: "http_response_event".to_string(), + id: Self::generate_id(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + workspace_id: workspace_id.to_string(), + response_id: response_id.to_string(), + event, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] @@ -2189,6 +2326,7 @@ define_any_model! { GrpcRequest, HttpRequest, HttpResponse, + HttpResponseEvent, KeyValue, Plugin, Settings, diff --git a/src-tauri/yaak-sync/src/models.rs b/src-tauri/yaak-sync/src/models.rs index 41d1fff9..9c3c75fe 100644 --- a/src-tauri/yaak-sync/src/models.rs +++ b/src-tauri/yaak-sync/src/models.rs @@ -208,6 +208,7 @@ impl TryFrom for SyncModel { AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)), AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)), + AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)), AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)), AnyModel::Plugin(m) => return Err(UnknownModel(m.model)), AnyModel::Settings(m) => return Err(UnknownModel(m.model)), diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index 6aea7918..c2d23e30 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -4,6 +4,7 @@ import type { ComponentType, CSSProperties } from 'react'; import { lazy, Suspense, useCallback, useMemo } from 'react'; import { useLocalStorage } from 'react-use'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; +import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { getMimeTypeFromContentType } from '../lib/contentType'; @@ -23,6 +24,7 @@ import { TabContent, Tabs } from './core/Tabs/Tabs'; import { EmptyStateText } from './EmptyStateText'; import { ErrorBoundary } from './ErrorBoundary'; import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown'; +import { ResponseEvents } from './ResponseEvents'; import { ResponseHeaders } from './ResponseHeaders'; import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; @@ -46,6 +48,7 @@ interface Props { const TAB_BODY = 'body'; const TAB_HEADERS = 'headers'; const TAB_INFO = 'info'; +const TAB_EVENTS = 'events'; export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); @@ -57,12 +60,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; + const responseEvents = useHttpResponseEvents(activeResponse); + const tabs = useMemo( () => [ { value: TAB_BODY, label: 'Preview Mode', - hidden: (activeResponse?.contentLength || 0) === 0, options: { value: viewMode, onChange: setViewMode, @@ -82,6 +86,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { /> ), }, + { + value: TAB_EVENTS, + label: 'Timeline', + rightSlot: , + }, { value: TAB_INFO, label: 'Info', @@ -93,7 +102,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { setViewMode, viewMode, activeResponse?.requestHeaders.length, - activeResponse?.contentLength, + responseEvents.data?.length, ], ); const activeTab = activeTabs?.[activeRequestId]; @@ -224,6 +233,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { + + +
diff --git a/src-web/components/ResponseEvents.tsx b/src-web/components/ResponseEvents.tsx new file mode 100644 index 00000000..fd32f569 --- /dev/null +++ b/src-web/components/ResponseEvents.tsx @@ -0,0 +1,341 @@ +import type { + HttpResponse, + HttpResponseEvent, + HttpResponseEventData, +} from '@yaakapp-internal/models'; +import classNames from 'classnames'; +import { format } from 'date-fns'; +import { Fragment, type ReactNode, useMemo, useState } from 'react'; +import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; +import { AutoScroller } from './core/AutoScroller'; +import { Banner } from './core/Banner'; +import { HttpMethodTagRaw } from './core/HttpMethodTag'; +import { HttpStatusTagRaw } from './core/HttpStatusTag'; +import { Icon, type IconProps } from './core/Icon'; +import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; +import { Separator } from './core/Separator'; +import { SplitLayout } from './core/SplitLayout'; + +interface Props { + response: HttpResponse; +} + +export function ResponseEvents({ response }: Props) { + return ( + + + + ); +} + +function ActualResponseEvents({ response }: Props) { + const [activeEventIndex, setActiveEventIndex] = useState(null); + const { data: events, error, isLoading } = useHttpResponseEvents(response); + + const activeEvent = useMemo( + () => (activeEventIndex == null ? null : events?.[activeEventIndex]), + [activeEventIndex, events], + ); + + if (isLoading) { + return
Loading events...
; + } + + if (error) { + return ( + + {String(error)} + + ); + } + + if (!events || events.length === 0) { + return
No events recorded
; + } + + return ( + ( + ( + { + if (i === activeEventIndex) setActiveEventIndex(null); + else setActiveEventIndex(i); + }} + /> + )} + /> + )} + secondSlot={ + activeEvent + ? () => ( +
+
+ +
+
+ +
+
+ ) + : null + } + /> + ); +} + +function EventRow({ + onClick, + isActive, + event, +}: { + onClick: () => void; + isActive: boolean; + event: HttpResponseEvent; +}) { + const display = getEventDisplay(event.event); + const { icon, color, summary } = display; + + return ( +
+ +
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function EventDetails({ event }: { event: HttpResponseEvent }) { + const { label } = getEventDisplay(event.event); + const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS'); + const e = event.event; + + // Headers - show name and value with Editor for JSON + if (e.type === 'header_up' || e.type === 'header_down') { + return ( +
+ + + {e.name} + {e.value} + +
+ ); + } + + // Request URL - show method and path separately + if (e.type === 'send_url') { + return ( +
+ + + + + + {e.path} + +
+ ); + } + + // Response status - show version and status separately + if (e.type === 'receive_url') { + return ( +
+ + + {e.version} + + + + +
+ ); + } + + // Redirect - show status, URL, and behavior + if (e.type === 'redirect') { + return ( +
+ + + + + + {e.url} + + {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} + + +
+ ); + } + + // Settings - show as key/value + if (e.type === 'setting') { + return ( +
+ + + {e.name} + {e.value} + +
+ ); + } + + // Chunks - show formatted bytes + if (e.type === 'chunk_sent' || e.type === 'chunk_received') { + const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received'; + return ( +
+ +
{formatBytes(e.bytes)}
+
+ ); + } + + // Default - use summary + const { summary } = getEventDisplay(event.event); + return ( +
+ +
{summary}
+
+ ); +} + +function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) { + return ( +
+

{title}

+ {timestamp} +
+ ); +} + +type EventDisplay = { + icon: IconProps['icon']; + color: IconProps['color']; + label: string; + summary: ReactNode; +}; + +function getEventDisplay(event: HttpResponseEventData): EventDisplay { + switch (event.type) { + case 'start_request': + return { + icon: 'info', + color: 'secondary', + label: 'Start', + summary: 'Request started', + }; + case 'end_request': + return { + icon: 'info', + color: 'secondary', + label: 'End', + summary: 'Request complete', + }; + case 'setting': + return { + icon: 'settings', + color: 'secondary', + label: 'Setting', + summary: `${event.name} = ${event.value}`, + }; + case 'info': + return { + icon: 'info', + color: 'secondary', + label: 'Info', + summary: event.message, + }; + case 'redirect': + return { + icon: 'arrow_big_right_dash', + color: 'warning', + label: 'Redirect', + summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, + }; + case 'send_url': + return { + icon: 'arrow_big_up_dash', + color: 'primary', + label: 'Request', + summary: `${event.method} ${event.path}`, + }; + case 'receive_url': + return { + icon: 'arrow_big_down_dash', + color: 'info', + label: 'Response', + summary: `${event.version} ${event.status}`, + }; + case 'header_up': + return { + icon: 'arrow_big_up_dash', + color: 'primary', + label: 'Header', + summary: `${event.name}: ${event.value}`, + }; + case 'header_down': + return { + icon: 'arrow_big_down_dash', + color: 'info', + label: 'Header', + summary: `${event.name}: ${event.value}`, + }; + + case 'chunk_sent': + return { + icon: 'info', + color: 'secondary', + label: 'Chunk', + summary: `${event.bytes} bytes sent`, + }; + case 'chunk_received': + return { + icon: 'info', + color: 'secondary', + label: 'Chunk', + summary: `${event.bytes} bytes received`, + }; + default: + return { + icon: 'info', + color: 'secondary', + label: 'Unknown', + summary: 'Unknown event', + }; + } +} diff --git a/src-web/components/core/HttpMethodTag.tsx b/src-web/components/core/HttpMethodTag.tsx index 486e1163..bdb0c832 100644 --- a/src-web/components/core/HttpMethodTag.tsx +++ b/src-web/components/core/HttpMethodTag.tsx @@ -41,10 +41,12 @@ export function HttpMethodTagRaw({ className, method, short, + forceColor, }: { method: string; className?: string; short?: boolean; + forceColor?: boolean; }) { let label = method.toUpperCase(); if (short) { @@ -54,7 +56,8 @@ export function HttpMethodTagRaw({ const m = method.toUpperCase(); - const colored = useAtomValue(settingsAtom).coloredMethods; + const settings = useAtomValue(settingsAtom); + const colored = forceColor || settings.coloredMethods; return ( ; +} +export function HttpStatusTagRaw({ + status, + state, + className, + showReason, + statusReason, + short, +}: Omit & { + status: number | string; + state?: HttpResponseState; + statusReason?: string | null; +}) { let colorClass: string; let label = `${status}`; + const statusN = typeof status === 'number' ? status : parseInt(status, 10); if (state === 'initialized') { label = short ? 'CONN' : 'CONNECTING'; colorClass = 'text-text-subtle'; - } else if (status < 100) { + } else if (statusN < 100) { label = short ? 'ERR' : 'ERROR'; colorClass = 'text-danger'; - } else if (status < 200) { + } else if (statusN < 200) { colorClass = 'text-info'; - } else if (status < 300) { + } else if (statusN < 300) { colorClass = 'text-success'; - } else if (status < 400) { + } else if (statusN < 400) { colorClass = 'text-primary'; - } else if (status < 500) { + } else if (statusN < 500) { colorClass = 'text-warning'; } else { colorClass = 'text-danger'; @@ -34,7 +50,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props) return ( - {label} {showReason && 'statusReason' in response ? response.statusReason : null} + {label} {showReason && statusReason} ); } diff --git a/src-web/hooks/useHttpResponseEvents.ts b/src-web/hooks/useHttpResponseEvents.ts new file mode 100644 index 00000000..6e0dff79 --- /dev/null +++ b/src-web/hooks/useHttpResponseEvents.ts @@ -0,0 +1,28 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models'; +import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models'; +import { useAtomValue } from 'jotai'; +import { useEffect, useMemo } from 'react'; + +export function useHttpResponseEvents(response: HttpResponse | null) { + const allEvents = useAtomValue(httpResponseEventsAtom); + + useEffect(() => { + if (response?.id == null) { + replaceModelsInStore('http_response_event', []); + return; + } + + invoke('cmd_get_http_response_events', { responseId: response.id }).then( + (events) => replaceModelsInStore('http_response_event', events), + ); + }, [response?.id]); + + // Filter events for the current response + const events = useMemo( + () => allEvents.filter((e) => e.responseId === response?.id), + [allEvents, response?.id], + ); + + return { data: events, error: null, isLoading: false }; +} diff --git a/src-web/lib/contentType.ts b/src-web/lib/contentType.ts index c2558281..c4fd82ec 100644 --- a/src-web/lib/contentType.ts +++ b/src-web/lib/contentType.ts @@ -13,7 +13,7 @@ export function languageFromContentType( return 'xml'; } if (justContentType.includes('html')) { - const detected = detectFromContent(content); + const detected = languageFromContent(content); if (detected === 'xml') { // If it's detected as XML, but is already HTML, don't change it return 'html'; @@ -22,16 +22,16 @@ export function languageFromContentType( } if (justContentType.includes('javascript')) { // Sometimes `application/javascript` returns JSON, so try detecting that - return detectFromContent(content, 'javascript'); + return languageFromContent(content, 'javascript'); } if (justContentType.includes('markdown')) { return 'markdown'; } - return detectFromContent(content, 'text'); + return languageFromContent(content, 'text'); } -function detectFromContent( +export function languageFromContent( content: string | null, fallback?: EditorProps['language'], ): EditorProps['language'] { diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 124ea691..41d6a166 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -17,6 +17,7 @@ type TauriCmd = | 'cmd_format_json' | 'cmd_get_http_authentication_config' | 'cmd_get_http_authentication_summaries' + | 'cmd_get_http_response_events' | 'cmd_get_sse_events' | 'cmd_get_themes' | 'cmd_get_workspace_meta' From 5f8902e57b00f7fabc969bbb14bf716e687efccf Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 22 Dec 2025 10:58:03 -0800 Subject: [PATCH 08/17] Fix cookies not being persisted after HTTP requests (#328) --- src-tauri/src/http_request.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index cc4ad156..97715188 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -2,7 +2,7 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::render::render_http_request; use crate::response_err; -use log::debug; +use log::{debug, warn}; use reqwest_cookie_store::{CookieStore, CookieStoreMutex}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -19,8 +19,8 @@ use yaak_http::sender::ReqwestSender; use yaak_http::transaction::HttpTransaction; use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params}; use yaak_models::models::{ - CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, - HttpResponseState, ProxySetting, ProxySettingAuth, + Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, + HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth, }; use yaak_models::query_manager::QueryManagerExt; use yaak_models::util::UpdateSource; @@ -207,6 +207,29 @@ async fn send_http_request_inner( ) .await; + // Persist cookies back to the database after the request completes + if let Some((cookie_store, mut cj)) = maybe_cookie_manager { + match cookie_store.lock() { + Ok(store) => { + let cookies: Vec = store + .iter_any() + .filter_map(|c| { + // Convert cookie_store::Cookie -> yaak_models::Cookie via serde + let json_cookie = serde_json::to_value(c).ok()?; + serde_json::from_value(json_cookie).ok() + }) + .collect(); + cj.cookies = cookies; + if let Err(e) = window.db().upsert_cookie_jar(&cj, &update_source) { + warn!("Failed to persist cookies to database: {}", e); + } + } + Err(e) => { + warn!("Failed to lock cookie store: {}", e); + } + } + } + match final_resp { Ok(r) => Ok(r), Err(e) => match app_handle.db().get_http_response(&resp_id) { From 9c5479b206f5e569f4cde26501452fa18304312b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 22 Dec 2025 14:40:18 -0800 Subject: [PATCH 09/17] Tweak font sizes --- src-web/components/HttpResponsePane.tsx | 10 +++++----- src-web/components/ResponseEvents.tsx | 20 ++++++++++---------- src-web/components/core/KeyValueRow.tsx | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index c2d23e30..a4d0d26e 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -24,7 +24,7 @@ import { TabContent, Tabs } from './core/Tabs/Tabs'; import { EmptyStateText } from './EmptyStateText'; import { ErrorBoundary } from './ErrorBoundary'; import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown'; -import { ResponseEvents } from './ResponseEvents'; +import { ResponseTimeline } from './ResponseEvents'; import { ResponseHeaders } from './ResponseHeaders'; import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; @@ -48,7 +48,7 @@ interface Props { const TAB_BODY = 'body'; const TAB_HEADERS = 'headers'; const TAB_INFO = 'info'; -const TAB_EVENTS = 'events'; +const TAB_TIMELINE = 'events'; export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); @@ -87,7 +87,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ), }, { - value: TAB_EVENTS, + value: TAB_TIMELINE, label: 'Timeline', rightSlot: , }, @@ -233,8 +233,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { - - + +
diff --git a/src-web/components/ResponseEvents.tsx b/src-web/components/ResponseEvents.tsx index fd32f569..3a40404b 100644 --- a/src-web/components/ResponseEvents.tsx +++ b/src-web/components/ResponseEvents.tsx @@ -20,15 +20,15 @@ interface Props { response: HttpResponse; } -export function ResponseEvents({ response }: Props) { +export function ResponseTimeline({ response }: Props) { return ( - + ); } -function ActualResponseEvents({ response }: Props) { +function Inner({ response }: Props) { const [activeEventIndex, setActiveEventIndex] = useState(null); const { data: events, error, isLoading } = useHttpResponseEvents(response); @@ -57,8 +57,8 @@ function ActualResponseEvents({ response }: Props) { ( -
{summary}
-
{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}
+
{summary}
+
{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}
); @@ -219,7 +219,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { return (
-
{formatBytes(e.bytes)}
+
{formatBytes(e.bytes)}
); } @@ -229,7 +229,7 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { return (
-
{summary}
+
{summary}
); } diff --git a/src-web/components/core/KeyValueRow.tsx b/src-web/components/core/KeyValueRow.tsx index a6ba7fc8..ec08d6ba 100644 --- a/src-web/components/core/KeyValueRow.tsx +++ b/src-web/components/core/KeyValueRow.tsx @@ -10,7 +10,7 @@ interface Props { export function KeyValueRows({ children }: Props) { children = Array.isArray(children) ? children : [children]; return ( - +
{children.map((child, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: none From 271d8f29ca9bd0523e894766a2116d6a760afa70 Mon Sep 17 00:00:00 2001 From: gschier Date: Fri, 26 Dec 2025 15:37:29 +0000 Subject: [PATCH 10/17] =?UTF-8?q?Deploying=20to=20main=20from=20@=20mounta?= =?UTF-8?q?in-loop/yaak@9c5479b206f5e569f4cde26501452fa18304312b=20?= =?UTF-8?q?=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8bb37fb7..327cfd0d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@

- User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar:    + User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar: bytebase  User avatar:   

User avatar: seanwash  User avatar: jerath  User avatar: itsa-sh  User avatar: dmmulroy  User avatar: timcole  User avatar: VLZH  User avatar: terasaka2k  User avatar: andriyor  User avatar: majudhu  User avatar: axelrindle  User avatar: jirizverina  User avatar: chip-well  User avatar: GRAYAH   From 6a0d5d2337889bd4beaea5143e5a558ba7812824 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 28 Dec 2025 07:07:20 -0800 Subject: [PATCH 11/17] Add Claude Code GitHub Workflow (#332) --- .github/workflows/claude-code-review.yml | 57 ++++++++++++++++++++++++ .github/workflows/claude.yml | 50 +++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..8452b0f2 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,57 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..d300267f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + From 26a3e88715944a7c15e8e003a05b2bbd42918c05 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 28 Dec 2025 08:07:42 -0800 Subject: [PATCH 12/17] Store and show request body in UI (#327) --- .gitattributes | 2 + .github/workflows/claude-code-review.yml | 57 --- .../src/bindings/gen_models.ts | 2 +- src-tauri/src/http_request.rs | 419 +++++++++++++----- src-tauri/src/lib.rs | 45 +- src-tauri/src/plugin_events.rs | 3 + src-tauri/yaak-http/src/lib.rs | 1 + src-tauri/yaak-http/src/sender.rs | 17 +- src-tauri/yaak-http/src/tee_reader.rs | 171 +++++++ src-tauri/yaak-http/src/transaction.rs | 14 +- src-tauri/yaak-models/bindings/gen_models.ts | 4 +- .../blob_migrations/00000000000000_init.sql | 12 + .../20251221100000_request-content-length.sql | 2 + src-tauri/yaak-models/src/blob_manager.rs | 372 ++++++++++++++++ src-tauri/yaak-models/src/commands.rs | 7 +- src-tauri/yaak-models/src/db_context.rs | 6 +- src-tauri/yaak-models/src/lib.rs | 23 + src-tauri/yaak-models/src/models.rs | 8 +- .../src/queries/http_response_events.rs | 18 + .../yaak-models/src/queries/http_responses.rs | 12 +- src-tauri/yaak-models/src/queries/mod.rs | 1 + src-tauri/yaak-plugins/bindings/gen_models.ts | 2 +- .../yaak-templates/pkg/yaak_templates.d.ts | 2 +- .../yaak-templates/pkg/yaak_templates.js | 3 +- .../yaak-templates/pkg/yaak_templates_bg.js | 141 ++---- .../yaak-templates/pkg/yaak_templates_bg.wasm | Bin 54673 -> 68448 bytes .../pkg/yaak_templates_bg.wasm.d.ts | 14 +- .../ConfirmLargeResponseRequest.tsx | 58 +++ src-web/components/HttpResponsePane.tsx | 29 +- ...nseEvents.tsx => HttpResponseTimeline.tsx} | 28 +- src-web/components/RequestBodyViewer.tsx | 52 +++ src-web/hooks/useHttpRequestBody.ts | 32 ++ src-web/lib/tauri.ts | 1 + 33 files changed, 1221 insertions(+), 337 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml create mode 100644 src-tauri/yaak-http/src/tee_reader.rs create mode 100644 src-tauri/yaak-models/blob_migrations/00000000000000_init.sql create mode 100644 src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql create mode 100644 src-tauri/yaak-models/src/blob_manager.rs create mode 100644 src-tauri/yaak-models/src/queries/http_response_events.rs create mode 100644 src-web/components/ConfirmLargeResponseRequest.tsx rename src-web/components/{ResponseEvents.tsx => HttpResponseTimeline.tsx} (93%) create mode 100644 src-web/components/RequestBodyViewer.tsx create mode 100644 src-web/hooks/useHttpRequestBody.ts diff --git a/.gitattributes b/.gitattributes index 84042659..0b6a9cb7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ src-tauri/vendored/**/* linguist-generated=true src-tauri/gen/schemas/**/* linguist-generated=true +**/bindings/* linguist-generated=true +src-tauri/yaak-templates/pkg/* linguist-generated=true # Ensure consistent line endings for test files that check exact content src-tauri/yaak-http/tests/test.txt text eol=lf diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 8452b0f2..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index 6b2eb5c8..454903fe 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -12,7 +12,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 97715188..c465e2e4 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -1,23 +1,27 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::render::render_http_request; -use crate::response_err; use log::{debug, warn}; use reqwest_cookie_store::{CookieStore, CookieStoreMutex}; +use std::pin::Pin; use std::sync::Arc; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; use tokio::fs::{File, create_dir_all}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::sync::Mutex; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::sync::watch::Receiver; +use tokio_util::bytes::Bytes; use yaak_http::client::{ HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth, }; use yaak_http::manager::HttpConnectionManager; use yaak_http::sender::ReqwestSender; +use yaak_http::tee_reader::TeeReader; use yaak_http::transaction::HttpTransaction; -use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params}; +use yaak_http::types::{ + SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params, +}; +use yaak_models::blob_manager::{BlobManagerExt, BodyChunk}; use yaak_models::models::{ Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth, @@ -32,6 +36,55 @@ use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_templates::RenderOptions; use yaak_tls::find_client_certificate; +/// Chunk size for storing request bodies (1MB) +const REQUEST_BODY_CHUNK_SIZE: usize = 1024 * 1024; + +/// Context for managing response state during HTTP transactions. +/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only). +struct ResponseContext { + app_handle: AppHandle, + response: HttpResponse, + update_source: UpdateSource, +} + +impl ResponseContext { + fn new(app_handle: AppHandle, response: HttpResponse, update_source: UpdateSource) -> Self { + Self { app_handle, response, update_source } + } + + /// Whether this response is persisted (has a non-empty ID) + fn is_persisted(&self) -> bool { + !self.response.id.is_empty() + } + + /// Update the response state. For persisted responses, fetches from DB, applies the + /// closure, and updates the DB. For ephemeral responses, just applies the closure + /// to the in-memory response. + fn update(&mut self, func: F) -> Result<()> + where + F: FnOnce(&mut HttpResponse), + { + if self.is_persisted() { + let r = self.app_handle.with_tx(|tx| { + let mut r = tx.get_http_response(&self.response.id)?; + func(&mut r); + tx.update_http_response_if_id(&r, &self.update_source)?; + Ok(r) + })?; + self.response = r; + Ok(()) + } else { + func(&mut self.response); + Ok(()) + } + } + + /// Get the current response state + fn response(&self) -> &HttpResponse { + &self.response + } +} + pub async fn send_http_request( window: &WebviewWindow, unrendered_request: &HttpRequest, @@ -62,25 +115,38 @@ pub async fn send_http_request_with_context( plugin_context: &PluginContext, ) -> Result { let app_handle = window.app_handle().clone(); - let response = Arc::new(Mutex::new(og_response.clone())); let update_source = UpdateSource::from_window(window); + let mut response_ctx = + ResponseContext::new(app_handle.clone(), og_response.clone(), update_source); // Execute the inner send logic and handle errors consistently + let start = Instant::now(); let result = send_http_request_inner( window, unrendered_request, - og_response, environment, cookie_jar, cancelled_rx, plugin_context, + &mut response_ctx, ) .await; match result { Ok(response) => Ok(response), Err(e) => { - Ok(response_err(&app_handle, &*response.lock().await, e.to_string(), &update_source)) + let error = e.to_string(); + let elapsed = start.elapsed().as_millis() as i32; + warn!("Failed to send request: {error:?}"); + let _ = response_ctx.update(|r| { + r.state = HttpResponseState::Closed; + r.elapsed = elapsed; + if r.elapsed_headers == 0 { + r.elapsed_headers = elapsed; + } + r.error = Some(error); + }); + Ok(response_ctx.response().clone()) } } } @@ -88,26 +154,24 @@ pub async fn send_http_request_with_context( async fn send_http_request_inner( window: &WebviewWindow, unrendered_request: &HttpRequest, - og_response: &HttpResponse, environment: Option, cookie_jar: Option, cancelled_rx: &Receiver, plugin_context: &PluginContext, + response_ctx: &mut ResponseContext, ) -> Result { let app_handle = window.app_handle().clone(); let plugin_manager = app_handle.state::(); let connection_manager = app_handle.state::(); let settings = window.db().get_settings(); - let wrk_id = &unrendered_request.workspace_id; - let fld_id = unrendered_request.folder_id.as_deref(); - let env_id = environment.map(|e| e.id); - let resp_id = og_response.id.clone(); - let workspace = window.db().get_workspace(wrk_id)?; - let response = Arc::new(Mutex::new(og_response.clone())); - let update_source = UpdateSource::from_window(window); + let workspace_id = &unrendered_request.workspace_id; + let folder_id = unrendered_request.folder_id.as_deref(); + let environment_id = environment.map(|e| e.id); + let workspace = window.db().get_workspace(workspace_id)?; let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?; let cb = PluginTemplateCallback::new(window.app_handle(), &plugin_context, RenderPurpose::Send); - let env_chain = window.db().resolve_environments(&workspace.id, fld_id, env_id.as_deref())?; + let env_chain = + window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?; let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?; // Build the sendable request using the new SendableHttpRequest type @@ -195,17 +259,30 @@ async fn send_http_request_inner( ) .await?; - let start_for_cancellation = Instant::now(); - let final_resp = execute_transaction( - client, - sendable_request, - response.clone(), - &resp_id, - &app_handle, - &update_source, - cancelled_rx.clone(), - ) - .await; + let result = + execute_transaction(client, sendable_request, response_ctx, cancelled_rx.clone()).await; + + // Wait for blob writing to complete and check for errors + let final_result = match result { + Ok((response, maybe_blob_write_handle)) => { + // Check if blob writing failed + if let Some(handle) = maybe_blob_write_handle { + if let Ok(Err(e)) = handle.await { + // Update response with the storage error + let _ = response_ctx.update(|r| { + let error_msg = + format!("Request succeeded but failed to store request body: {}", e); + r.error = Some(match &r.error { + Some(existing) => format!("{}; {}", existing, error_msg), + None => error_msg, + }); + }); + } + } + Ok(response) + } + Err(e) => Err(e), + }; // Persist cookies back to the database after the request completes if let Some((cookie_store, mut cj)) = maybe_cookie_manager { @@ -220,7 +297,7 @@ async fn send_http_request_inner( }) .collect(); cj.cookies = cookies; - if let Err(e) = window.db().upsert_cookie_jar(&cj, &update_source) { + if let Err(e) = window.db().upsert_cookie_jar(&cj, &UpdateSource::Background) { warn!("Failed to persist cookies to database: {}", e); } } @@ -230,23 +307,7 @@ async fn send_http_request_inner( } } - match final_resp { - Ok(r) => Ok(r), - Err(e) => match app_handle.db().get_http_response(&resp_id) { - Ok(mut r) => { - r.state = HttpResponseState::Closed; - r.elapsed = start_for_cancellation.elapsed().as_millis() as i32; - r.elapsed_headers = start_for_cancellation.elapsed().as_millis() as i32; - r.error = Some(e.to_string()); - app_handle - .db() - .update_http_response_if_id(&r, &UpdateSource::from_window(window)) - .expect("Failed to update response"); - Ok(r) - } - _ => Err(GenericError("Ephemeral request was cancelled".to_string())), - }, - } + final_result } pub fn resolve_http_request( @@ -268,13 +329,15 @@ pub fn resolve_http_request( async fn execute_transaction( client: reqwest::Client, - sendable_request: SendableHttpRequest, - response: Arc>, - response_id: &String, - app_handle: &AppHandle, - update_source: &UpdateSource, + mut sendable_request: SendableHttpRequest, + response_ctx: &mut ResponseContext, mut cancelled_rx: Receiver, -) -> Result { +) -> Result<(HttpResponse, Option>>)> { + let app_handle = &response_ctx.app_handle.clone(); + let response_id = response_ctx.response().id.clone(); + let workspace_id = response_ctx.response().workspace_id.clone(); + let is_persisted = response_ctx.is_persisted(); + let sender = ReqwestSender::with_client(client); let transaction = HttpTransaction::new(sender); let start = Instant::now(); @@ -286,30 +349,85 @@ async fn execute_transaction( .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(); - { - // Update response with headers info and mark as connected - let mut r = response.lock().await; + // Update response with headers info + response_ctx.update(|r| { r.url = sendable_request.url.clone(); - r.request_headers = request_headers.clone(); - app_handle.db().update_http_response_if_id(&r, &update_source)?; - } + r.request_headers = request_headers; + })?; - // Create channel for receiving events and spawn a task to store them in DB + // Create bounded channel for receiving events and spawn a task to store them in DB + // Buffer size of 100 events provides backpressure if DB writes are slow let (event_tx, mut event_rx) = - tokio::sync::mpsc::unbounded_channel::(); + tokio::sync::mpsc::channel::(100); - // Write events to DB in a task - { + // Write events to DB in a task (only for persisted responses) + if is_persisted { let response_id = response_id.clone(); - let workspace_id = response.lock().await.workspace_id.clone(); let app_handle = app_handle.clone(); - let update_source = update_source.clone(); + let update_source = response_ctx.update_source.clone(); + let workspace_id = workspace_id.clone(); tokio::spawn(async move { while let Some(event) = event_rx.recv().await { let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into()); - let _ = app_handle.db().upsert(&db_event, &update_source); + let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source); } }); + } else { + // For ephemeral responses, just drain the events + tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); + }; + + // Capture request body as it's sent (only for persisted responses) + let body_id = format!("{}.request", response_id); + let maybe_blob_write_handle = match sendable_request.body { + Some(SendableBody::Bytes(bytes)) => { + if is_persisted { + write_bytes_to_db_sync(response_ctx, &body_id, bytes.clone())?; + } + sendable_request.body = Some(SendableBody::Bytes(bytes)); + None + } + Some(SendableBody::Stream(stream)) => { + // Wrap stream with TeeReader to capture data as it's read + // Bounded channel with buffer size of 10 chunks (~10MB) provides backpressure + let (body_chunk_tx, body_chunk_rx) = tokio::sync::mpsc::channel::>(10); + let tee_reader = TeeReader::new(stream, body_chunk_tx); + let pinned: Pin> = Box::pin(tee_reader); + + let handle = if is_persisted { + // Spawn task to write request body chunks to blob DB + let app_handle = app_handle.clone(); + let response_id = response_id.clone(); + let workspace_id = workspace_id.clone(); + let body_id = body_id.clone(); + let update_source = response_ctx.update_source.clone(); + Some(tauri::async_runtime::spawn(async move { + write_stream_chunks_to_db( + app_handle, + &body_id, + &workspace_id, + &response_id, + &update_source, + body_chunk_rx, + ) + .await + })) + } else { + // For ephemeral responses, just drain the body chunks + tauri::async_runtime::spawn(async move { + let mut rx = body_chunk_rx; + while rx.recv().await.is_some() {} + }); + None + }; + + sendable_request.body = Some(SendableBody::Stream(pinned)); + handle + } + None => { + sendable_request.body = None; + None + } }; // Execute the transaction with cancellation support @@ -320,44 +438,42 @@ async fn execute_transaction( .await?; // Prepare the response path before consuming the body - let dir = app_handle.path().app_data_dir()?; - let base_dir = dir.join("responses"); - create_dir_all(&base_dir).await?; - let body_path = if response_id.is_empty() { - base_dir.join(uuid::Uuid::new_v4().to_string()) + // Ephemeral responses: use OS temp directory for automatic cleanup + let temp_dir = std::env::temp_dir().join("yaak-ephemeral-responses"); + create_dir_all(&temp_dir).await?; + temp_dir.join(uuid::Uuid::new_v4().to_string()) } else { + // Persisted responses: use app data directory + let dir = app_handle.path().app_data_dir()?; + let base_dir = dir.join("responses"); + create_dir_all(&base_dir).await?; base_dir.join(&response_id) }; // Extract metadata before consuming the body (headers are available immediately) // Url might change, so update again - let headers: Vec = http_response - .headers - .iter() - .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) - .collect(); - - { - // Update response with headers info and mark as connected - let mut r = response.lock().await; + response_ctx.update(|r| { r.body_path = Some(body_path.to_string_lossy().to_string()); r.elapsed_headers = start.elapsed().as_millis() as i32; r.status = http_response.status as i32; - r.status_reason = http_response.status_reason.clone().clone(); - r.url = http_response.url.clone().clone(); + r.status_reason = http_response.status_reason.clone(); + r.url = http_response.url.clone(); r.remote_addr = http_response.remote_addr.clone(); - r.version = http_response.version.clone().clone(); - r.headers = headers.clone(); - r.content_length = http_response.content_length.map(|l| l as i32); + r.version = http_response.version.clone(); + r.headers = http_response + .headers + .iter() + .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) + .collect(); + r.content_length = http_response.content_length.map(|l| l as i64); + r.state = HttpResponseState::Connected; r.request_headers = http_response .request_headers .iter() .map(|(n, v)| HttpResponseHeader { name: n.clone(), value: v.clone() }) .collect(); - r.state = HttpResponseState::Connected; - app_handle.db().update_http_response_if_id(&r, &update_source)?; - } + })?; // Get the body stream for manual consumption let mut body_stream = http_response.into_body_stream()?; @@ -371,10 +487,14 @@ async fn execute_transaction( .await .map_err(|e| GenericError(format!("Failed to open file: {}", e)))?; - // Stream body to file, updating DB on each chunk + // Stream body to file, with throttled DB updates to avoid excessive writes let mut written_bytes: usize = 0; + let mut last_update_time = start; let mut buf = [0u8; 8192]; + // Throttle settings: update DB at most every 100ms + const UPDATE_INTERVAL_MS: u128 = 100; + loop { // Check for cancellation. If we already have headers/body, just close cleanly without error if *cancelled_rx.borrow() { @@ -401,11 +521,17 @@ async fn execute_transaction( .map_err(|e| GenericError(format!("Failed to flush file: {}", e)))?; written_bytes += n; - // Update response in DB with progress - let mut r = response.lock().await; - r.elapsed = start.elapsed().as_millis() as i32; // Approx until the end - r.content_length = Some(written_bytes as i32); - app_handle.db().update_http_response_if_id(&r, &update_source)?; + // Throttle DB updates: only update if enough time has passed + let now = Instant::now(); + let elapsed_since_update = now.duration_since(last_update_time).as_millis(); + + if elapsed_since_update >= UPDATE_INTERVAL_MS { + response_ctx.update(|r| { + r.elapsed = start.elapsed().as_millis() as i32; + r.content_length = Some(written_bytes as i64); + })?; + last_update_time = now; + } } Err(e) => { return Err(GenericError(format!("Failed to read response body: {}", e))); @@ -413,17 +539,108 @@ async fn execute_transaction( } } - // Final update with closed state - let mut resp = response.lock().await.clone(); - resp.elapsed = start.elapsed().as_millis() as i32; - resp.state = HttpResponseState::Closed; - resp.body_path = Some( - body_path.to_str().ok_or(GenericError(format!("Invalid path {body_path:?}",)))?.to_string(), - ); + // Final update with closed state and accurate byte count + response_ctx.update(|r| { + r.elapsed = start.elapsed().as_millis() as i32; + r.content_length = Some(written_bytes as i64); + r.state = HttpResponseState::Closed; + })?; - app_handle.db().update_http_response_if_id(&resp, &update_source)?; + Ok((response_ctx.response().clone(), maybe_blob_write_handle)) +} - Ok(resp) +fn write_bytes_to_db_sync( + response_ctx: &mut ResponseContext, + body_id: &str, + data: Bytes, +) -> Result<()> { + if data.is_empty() { + return Ok(()); + } + + // Write in chunks if data is large + let mut offset = 0; + let mut chunk_index = 0; + while offset < data.len() { + let end = std::cmp::min(offset + REQUEST_BODY_CHUNK_SIZE, data.len()); + let chunk_data = data.slice(offset..end).to_vec(); + let chunk = BodyChunk::new(body_id, chunk_index, chunk_data); + response_ctx.app_handle.blobs().insert_chunk(&chunk)?; + offset = end; + chunk_index += 1; + } + + // Update the response with the total request body size + response_ctx.update(|r| { + r.request_content_length = Some(data.len() as i64); + })?; + + Ok(()) +} + +async fn write_stream_chunks_to_db( + app_handle: AppHandle, + body_id: &str, + workspace_id: &str, + response_id: &str, + update_source: &UpdateSource, + mut rx: tokio::sync::mpsc::Receiver>, +) -> Result<()> { + let mut buffer = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE); + let mut chunk_index = 0; + let mut total_bytes: usize = 0; + + while let Some(data) = rx.recv().await { + total_bytes += data.len(); + buffer.extend_from_slice(&data); + + // Flush when buffer reaches chunk size + while buffer.len() >= REQUEST_BODY_CHUNK_SIZE { + debug!("Writing chunk {chunk_index} to DB"); + let chunk_data: Vec = buffer.drain(..REQUEST_BODY_CHUNK_SIZE).collect(); + let chunk = BodyChunk::new(body_id, chunk_index, chunk_data); + app_handle.blobs().insert_chunk(&chunk)?; + app_handle.db().upsert_http_response_event( + &HttpResponseEvent::new( + response_id, + workspace_id, + yaak_http::sender::HttpResponseEvent::ChunkSent { + bytes: REQUEST_BODY_CHUNK_SIZE, + } + .into(), + ), + update_source, + )?; + chunk_index += 1; + } + } + + // Flush remaining data + if !buffer.is_empty() { + let chunk = BodyChunk::new(body_id, chunk_index, buffer); + debug!("Flushing remaining data {chunk_index} {}", chunk.data.len()); + app_handle.blobs().insert_chunk(&chunk)?; + app_handle.db().upsert_http_response_event( + &HttpResponseEvent::new( + response_id, + workspace_id, + yaak_http::sender::HttpResponseEvent::ChunkSent { bytes: chunk.data.len() }.into(), + ), + update_source, + )?; + } + + // Update the response with the total request body size + app_handle.with_tx(|tx| { + debug!("Updating final body length {total_bytes}"); + if let Ok(mut response) = tx.get_http_response(&response_id) { + response.request_content_length = Some(total_bytes as i64); + tx.update_http_response_if_id(&response, update_source)?; + } + Ok(()) + })?; + + Ok(()) } async fn apply_authentication( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1f77b4c..2aacc65d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ use yaak_common::window::WorkspaceWindowTrait; use yaak_grpc::manager::GrpcHandle; use yaak_grpc::{Code, ServiceDefinition, serialize_message}; use yaak_mac_window::AppHandleMacWindowExt; +use yaak_models::blob_manager::BlobManagerExt; use yaak_models::models::{ AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, @@ -784,7 +785,7 @@ async fn cmd_http_response_body( ) -> YaakResult { let body_path = match response.body_path { None => { - return Err(GenericError("Response body path not set".to_string())); + return Ok(FilterResponse { content: String::new(), error: None }); } Some(p) => p, }; @@ -809,6 +810,23 @@ async fn cmd_http_response_body( } } +#[tauri::command] +async fn cmd_http_request_body( + app_handle: AppHandle, + response_id: &str, +) -> YaakResult>> { + let body_id = format!("{}.request", response_id); + let chunks = app_handle.blobs().get_chunks(&body_id)?; + + if chunks.is_empty() { + return Ok(None); + } + + // Concatenate all chunks + let body: Vec = chunks.into_iter().flat_map(|c| c.data).collect(); + Ok(Some(body)) +} + #[tauri::command] async fn cmd_get_sse_events(file_path: &str) -> YaakResult> { let body = fs::read(file_path)?; @@ -835,9 +853,7 @@ async fn cmd_get_http_response_events( app_handle: AppHandle, response_id: &str, ) -> YaakResult> { - use yaak_models::models::HttpResponseEventIden; - let events: Vec = - app_handle.db().find_many(HttpResponseEventIden::ResponseId, response_id, None)?; + let events: Vec = app_handle.db().list_http_response_events(response_id)?; Ok(events) } @@ -1115,6 +1131,7 @@ async fn cmd_send_http_request( // that has not yet been saved in the DB. request: HttpRequest, ) -> YaakResult { + let blobs = app_handle.blob_manager(); let response = app_handle.db().upsert_http_response( &HttpResponse { request_id: request.id.clone(), @@ -1122,6 +1139,7 @@ async fn cmd_send_http_request( ..Default::default() }, &UpdateSource::from_window(&window), + &blobs, )?; let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); @@ -1167,6 +1185,7 @@ async fn cmd_send_http_request( ..resp }, &UpdateSource::from_window(&window), + &blobs, )? } }; @@ -1174,23 +1193,6 @@ async fn cmd_send_http_request( Ok(r) } -fn response_err( - app_handle: &AppHandle, - response: &HttpResponse, - error: String, - update_source: &UpdateSource, -) -> HttpResponse { - warn!("Failed to send request: {error:?}"); - let mut response = response.clone(); - response.state = HttpResponseState::Closed; - response.error = Some(error.clone()); - response = app_handle - .db() - .update_http_response_if_id(&response, update_source) - .expect("Failed to update response"); - response -} - #[tauri::command] async fn cmd_install_plugin( directory: &str, @@ -1468,6 +1470,7 @@ pub fn run() { cmd_delete_send_history, cmd_dismiss_notification, cmd_export_data, + cmd_http_request_body, cmd_http_response_body, cmd_format_json, cmd_get_http_authentication_summaries, diff --git a/src-tauri/src/plugin_events.rs b/src-tauri/src/plugin_events.rs index b89c187b..4c1d95d1 100644 --- a/src-tauri/src/plugin_events.rs +++ b/src-tauri/src/plugin_events.rs @@ -12,6 +12,7 @@ use log::error; use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_clipboard_manager::ClipboardExt; use yaak_common::window::WorkspaceWindowTrait; +use yaak_models::blob_manager::BlobManagerExt; use yaak_models::models::{HttpResponse, Plugin}; use yaak_models::queries::any_request::AnyRequest; use yaak_models::query_manager::QueryManagerExt; @@ -194,6 +195,7 @@ pub(crate) async fn handle_plugin_event( let http_response = if http_request.id.is_empty() { HttpResponse::default() } else { + let blobs = window.blob_manager(); window.db().upsert_http_response( &HttpResponse { request_id: http_request.id.clone(), @@ -201,6 +203,7 @@ pub(crate) async fn handle_plugin_event( ..Default::default() }, &UpdateSource::Plugin, + &blobs, )? }; diff --git a/src-tauri/yaak-http/src/lib.rs b/src-tauri/yaak-http/src/lib.rs index 387b7419..8c25fe7d 100644 --- a/src-tauri/yaak-http/src/lib.rs +++ b/src-tauri/yaak-http/src/lib.rs @@ -11,6 +11,7 @@ pub mod manager; pub mod path_placeholders; mod proto; pub mod sender; +pub mod tee_reader; pub mod transaction; pub mod types; diff --git a/src-tauri/yaak-http/src/sender.rs b/src-tauri/yaak-http/src/sender.rs index d6cb88ea..1d56cff4 100644 --- a/src-tauri/yaak-http/src/sender.rs +++ b/src-tauri/yaak-http/src/sender.rs @@ -110,12 +110,12 @@ pub struct BodyStats { /// An AsyncRead wrapper that sends chunk events as data is read pub struct TrackingRead { inner: R, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ended: bool, } impl TrackingRead { - pub fn new(inner: R, event_tx: mpsc::UnboundedSender) -> Self { + pub fn new(inner: R, event_tx: mpsc::Sender) -> Self { Self { inner, event_tx, ended: false } } } @@ -131,8 +131,9 @@ impl AsyncRead for TrackingRead { if let Poll::Ready(Ok(())) = &result { let bytes_read = buf.filled().len() - before; if bytes_read > 0 { - // Ignore send errors - receiver may have been dropped - let _ = self.event_tx.send(HttpResponseEvent::ChunkReceived { bytes: bytes_read }); + // Ignore send errors - receiver may have been dropped or channel is full + let _ = + self.event_tx.try_send(HttpResponseEvent::ChunkReceived { bytes: bytes_read }); } else if !self.ended { self.ended = true; } @@ -311,7 +312,7 @@ pub trait HttpSender: Send + Sync { async fn send( &self, request: SendableHttpRequest, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ) -> Result; } @@ -338,11 +339,11 @@ impl HttpSender for ReqwestSender { async fn send( &self, request: SendableHttpRequest, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ) -> Result { - // Helper to send events (ignores errors if receiver is dropped) + // Helper to send events (ignores errors if receiver is dropped or channel is full) let send_event = |event: HttpResponseEvent| { - let _ = event_tx.send(event); + let _ = event_tx.try_send(event); }; // Parse the HTTP method diff --git a/src-tauri/yaak-http/src/tee_reader.rs b/src-tauri/yaak-http/src/tee_reader.rs new file mode 100644 index 00000000..b70372a2 --- /dev/null +++ b/src-tauri/yaak-http/src/tee_reader.rs @@ -0,0 +1,171 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::mpsc; + +/// A reader that forwards all read data to a channel while also returning it to the caller. +/// This allows capturing request body data as it's being sent. +/// Uses a bounded channel to provide backpressure if the receiver is slow. +pub struct TeeReader { + inner: R, + tx: mpsc::Sender>, +} + +impl TeeReader { + pub fn new(inner: R, tx: mpsc::Sender>) -> Self { + Self { inner, tx } + } +} + +impl AsyncRead for TeeReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before_len = buf.filled().len(); + + match Pin::new(&mut self.inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + let after_len = buf.filled().len(); + if after_len > before_len { + // Data was read, send a copy to the channel + let data = buf.filled()[before_len..after_len].to_vec(); + // Use try_send to avoid blocking. If channel is full, we drop the data + // rather than blocking the HTTP request. This provides backpressure + // by slowing down the reader when the DB writer can't keep up. + match self.tx.try_send(data) { + Ok(_) => {} // Successfully sent + Err(mpsc::error::TrySendError::Full(_)) => { + // Channel is full - apply backpressure by returning Pending + // This will cause the reader to be polled again later + cx.waker().wake_by_ref(); + return Poll::Pending; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + // Receiver dropped - continue without capturing + } + } + } + Poll::Ready(Ok(())) + } + Poll::Ready(Err(e)) => Poll::Ready(Err(e)), + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use tokio::io::AsyncReadExt; + + #[tokio::test] + async fn test_tee_reader_captures_all_data() { + let data = b"Hello, World!"; + let cursor = Cursor::new(data.to_vec()); + let (tx, mut rx) = mpsc::channel(10); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + tee.read_to_end(&mut output).await.unwrap(); + + // Verify the reader returns the correct data + assert_eq!(output, data); + + // Verify the channel received the data + let mut captured = Vec::new(); + while let Ok(chunk) = rx.try_recv() { + captured.extend(chunk); + } + assert_eq!(captured, data); + } + + #[tokio::test] + async fn test_tee_reader_with_chunked_reads() { + let data = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let cursor = Cursor::new(data.to_vec()); + let (tx, mut rx) = mpsc::channel(10); + + let mut tee = TeeReader::new(cursor, tx); + + // Read in small chunks + let mut buf = [0u8; 5]; + let mut output = Vec::new(); + loop { + let n = tee.read(&mut buf).await.unwrap(); + if n == 0 { + break; + } + output.extend_from_slice(&buf[..n]); + } + + // Verify the reader returns the correct data + assert_eq!(output, data); + + // Verify the channel received all chunks + let mut captured = Vec::new(); + while let Ok(chunk) = rx.try_recv() { + captured.extend(chunk); + } + assert_eq!(captured, data); + } + + #[tokio::test] + async fn test_tee_reader_empty_data() { + let data: Vec = vec![]; + let cursor = Cursor::new(data.clone()); + let (tx, mut rx) = mpsc::channel(10); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + tee.read_to_end(&mut output).await.unwrap(); + + // Verify empty output + assert!(output.is_empty()); + + // Verify no data was sent to channel + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn test_tee_reader_works_when_receiver_dropped() { + let data = b"Hello, World!"; + let cursor = Cursor::new(data.to_vec()); + let (tx, rx) = mpsc::channel(10); + + // Drop the receiver before reading + drop(rx); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + + // Should still work even though receiver is dropped + tee.read_to_end(&mut output).await.unwrap(); + assert_eq!(output, data); + } + + #[tokio::test] + async fn test_tee_reader_large_data() { + // Test with 1MB of data + let data: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); + let cursor = Cursor::new(data.clone()); + let (tx, mut rx) = mpsc::channel(100); + + let mut tee = TeeReader::new(cursor, tx); + let mut output = Vec::new(); + tee.read_to_end(&mut output).await.unwrap(); + + // Verify the reader returns the correct data + assert_eq!(output, data); + + // Verify the channel received all data + let mut captured = Vec::new(); + while let Ok(chunk) = rx.try_recv() { + captured.extend(chunk); + } + assert_eq!(captured, data); + } +} diff --git a/src-tauri/yaak-http/src/transaction.rs b/src-tauri/yaak-http/src/transaction.rs index d779e9f5..43a59de0 100644 --- a/src-tauri/yaak-http/src/transaction.rs +++ b/src-tauri/yaak-http/src/transaction.rs @@ -28,7 +28,7 @@ impl HttpTransaction { &self, request: SendableHttpRequest, mut cancelled_rx: Receiver, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::Sender, ) -> Result { let mut redirect_count = 0; let mut current_url = request.url; @@ -36,9 +36,9 @@ impl HttpTransaction { let mut current_headers = request.headers; let mut current_body = request.body; - // Helper to send events (ignores errors if receiver is dropped) + // Helper to send events (ignores errors if receiver is dropped or channel is full) let send_event = |event: HttpResponseEvent| { - let _ = event_tx.send(event); + let _ = event_tx.try_send(event); }; loop { @@ -236,7 +236,7 @@ mod tests { async fn send( &self, _request: SendableHttpRequest, - _event_tx: mpsc::UnboundedSender, + _event_tx: mpsc::Sender, ) -> Result { let mut responses = self.responses.lock().await; if responses.is_empty() { @@ -276,7 +276,7 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); @@ -309,7 +309,7 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); @@ -341,7 +341,7 @@ mod tests { }; let (_tx, rx) = tokio::sync::watch::channel(false); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await; if let Err(crate::error::Error::RequestError(msg)) = result { assert!(msg.contains("Maximum redirect limit")); diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index f3742077..844c0deb 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -38,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; @@ -47,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "start_request" } | { "type": "end_request" } | { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; +export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-tauri/yaak-models/blob_migrations/00000000000000_init.sql b/src-tauri/yaak-models/blob_migrations/00000000000000_init.sql new file mode 100644 index 00000000..7589cb4e --- /dev/null +++ b/src-tauri/yaak-models/blob_migrations/00000000000000_init.sql @@ -0,0 +1,12 @@ +CREATE TABLE body_chunks +( + id TEXT PRIMARY KEY, + body_id TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + data BLOB NOT NULL, + created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, + + UNIQUE (body_id, chunk_index) +); + +CREATE INDEX idx_body_chunks_body_id ON body_chunks (body_id, chunk_index); diff --git a/src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql b/src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql new file mode 100644 index 00000000..16625cf5 --- /dev/null +++ b/src-tauri/yaak-models/migrations/20251221100000_request-content-length.sql @@ -0,0 +1,2 @@ +ALTER TABLE http_responses + ADD COLUMN request_content_length INTEGER; diff --git a/src-tauri/yaak-models/src/blob_manager.rs b/src-tauri/yaak-models/src/blob_manager.rs new file mode 100644 index 00000000..cb817db9 --- /dev/null +++ b/src-tauri/yaak-models/src/blob_manager.rs @@ -0,0 +1,372 @@ +use crate::error::Result; +use crate::util::generate_prefixed_id; +use include_dir::{Dir, include_dir}; +use log::{debug, info}; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::{OptionalExtension, params}; +use std::sync::{Arc, Mutex}; +use tauri::{Manager, Runtime, State}; + +static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations"); + +/// A chunk of body data stored in the blob database. +#[derive(Debug, Clone)] +pub struct BodyChunk { + pub id: String, + pub body_id: String, + pub chunk_index: i32, + pub data: Vec, +} + +impl BodyChunk { + pub fn new(body_id: impl Into, chunk_index: i32, data: Vec) -> Self { + Self { id: generate_prefixed_id("bc"), body_id: body_id.into(), chunk_index, data } + } +} + +/// Extension trait for accessing the blob manager from app handle. +pub trait BlobManagerExt<'a, R> { + fn blob_manager(&'a self) -> State<'a, BlobManager>; + fn blobs(&'a self) -> BlobContext; +} + +impl<'a, R: Runtime, M: Manager> BlobManagerExt<'a, R> for M { + fn blob_manager(&'a self) -> State<'a, BlobManager> { + self.state::() + } + + fn blobs(&'a self) -> BlobContext { + let manager = self.state::(); + manager.inner().connect() + } +} + +/// Manages the blob database connection pool. +#[derive(Debug, Clone)] +pub struct BlobManager { + pool: Arc>>, +} + +impl BlobManager { + pub fn new(pool: Pool) -> Self { + Self { pool: Arc::new(Mutex::new(pool)) } + } + + pub fn connect(&self) -> BlobContext { + let conn = self + .pool + .lock() + .expect("Failed to gain lock on blob DB") + .get() + .expect("Failed to get blob DB connection from pool"); + BlobContext { conn } + } +} + +/// Context for blob database operations. +pub struct BlobContext { + conn: r2d2::PooledConnection, +} + +impl BlobContext { + /// Insert a single chunk. + pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> { + self.conn.execute( + "INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)", + params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data], + )?; + Ok(()) + } + + /// Get all chunks for a body, ordered by chunk_index. + pub fn get_chunks(&self, body_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, body_id, chunk_index, data FROM body_chunks + WHERE body_id = ?1 ORDER BY chunk_index ASC", + )?; + + let chunks = stmt + .query_map(params![body_id], |row| { + Ok(BodyChunk { + id: row.get(0)?, + body_id: row.get(1)?, + chunk_index: row.get(2)?, + data: row.get(3)?, + }) + })? + .collect::, _>>()?; + + Ok(chunks) + } + + /// Delete all chunks for a body. + pub fn delete_chunks(&self, body_id: &str) -> Result<()> { + self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?; + Ok(()) + } + + /// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all bodies for a response). + pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> { + self.conn + .execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?; + Ok(()) + } +} + +/// Get total size of a body without loading data. +impl BlobContext { + pub fn get_body_size(&self, body_id: &str) -> Result { + let size: i64 = self + .conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1", + params![body_id], + |row| row.get(0), + ) + .unwrap_or(0); + Ok(size as usize) + } + + /// Check if a body exists. + pub fn body_exists(&self, body_id: &str) -> Result { + let count: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1", + params![body_id], + |row| row.get(0), + ) + .unwrap_or(0); + Ok(count > 0) + } +} + +/// Run migrations for the blob database. +pub fn migrate_blob_db(pool: &Pool) -> Result<()> { + info!("Running blob database migrations"); + + // Create migrations tracking table + pool.get()?.execute( + "CREATE TABLE IF NOT EXISTS _blob_migrations ( + version TEXT PRIMARY KEY, + description TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + )", + [], + )?; + + // Read and sort all .sql files + let mut entries: Vec<_> = BLOB_MIGRATIONS_DIR + .entries() + .iter() + .filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false)) + .collect(); + + entries.sort_by_key(|e| e.path()); + + let mut ran_migrations = 0; + for entry in &entries { + let filename = entry.path().file_name().unwrap().to_str().unwrap(); + let version = filename.split('_').next().unwrap(); + + // Check if already applied + let already_applied: Option = pool + .get()? + .query_row("SELECT 1 FROM _blob_migrations WHERE version = ?", [version], |r| r.get(0)) + .optional()?; + + if already_applied.is_some() { + debug!("Skipping already applied blob migration: {}", filename); + continue; + } + + let sql = + entry.as_file().unwrap().contents_utf8().expect("Failed to read blob migration file"); + + info!("Applying blob migration: {}", filename); + let conn = pool.get()?; + conn.execute_batch(sql)?; + + // Record migration + conn.execute( + "INSERT INTO _blob_migrations (version, description) VALUES (?, ?)", + params![version, filename], + )?; + + ran_migrations += 1; + } + + if ran_migrations == 0 { + info!("No blob migrations to run"); + } else { + info!("Ran {} blob migration(s)", ran_migrations); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_pool() -> Pool { + let manager = SqliteConnectionManager::memory(); + let pool = Pool::builder().max_size(1).build(manager).unwrap(); + migrate_blob_db(&pool).unwrap(); + pool + } + + #[test] + fn test_insert_and_get_chunks() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec()); + let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec()); + + ctx.insert_chunk(&chunk1).unwrap(); + ctx.insert_chunk(&chunk2).unwrap(); + + let chunks = ctx.get_chunks(body_id).unwrap(); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].chunk_index, 0); + assert_eq!(chunks[0].data, b"Hello, "); + assert_eq!(chunks[1].chunk_index, 1); + assert_eq!(chunks[1].data, b"World!"); + } + + #[test] + fn test_get_chunks_ordered_by_index() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + + // Insert out of order + ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap(); + + let chunks = ctx.get_chunks(body_id).unwrap(); + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[0].data, b"A"); + assert_eq!(chunks[1].data, b"B"); + assert_eq!(chunks[2].data, b"C"); + } + + #[test] + fn test_delete_chunks() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap(); + + assert!(ctx.body_exists(body_id).unwrap()); + + ctx.delete_chunks(body_id).unwrap(); + + assert!(!ctx.body_exists(body_id).unwrap()); + assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0); + } + + #[test] + fn test_delete_chunks_like() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + // Insert chunks for same response but different body types + ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap(); + + // Delete all bodies for rs_abc + ctx.delete_chunks_like("rs_abc.%").unwrap(); + + // rs_abc bodies should be gone + assert!(!ctx.body_exists("rs_abc.request").unwrap()); + assert!(!ctx.body_exists("rs_abc.response").unwrap()); + + // rs_other should still exist + assert!(ctx.body_exists("rs_other.request").unwrap()); + } + + #[test] + fn test_get_body_size() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let body_id = "rs_test123.request"; + ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap(); + + let size = ctx.get_body_size(body_id).unwrap(); + assert_eq!(size, 10); // "Hello" + "World" = 10 bytes + } + + #[test] + fn test_get_body_size_empty() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + let size = ctx.get_body_size("nonexistent").unwrap(); + assert_eq!(size, 0); + } + + #[test] + fn test_body_exists() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + assert!(!ctx.body_exists("rs_test.request").unwrap()); + + ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap(); + + assert!(ctx.body_exists("rs_test.request").unwrap()); + } + + #[test] + fn test_multiple_bodies_isolated() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap(); + ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap(); + + let chunks1 = ctx.get_chunks("body1").unwrap(); + let chunks2 = ctx.get_chunks("body2").unwrap(); + + assert_eq!(chunks1.len(), 1); + assert_eq!(chunks1[0].data, b"data1"); + assert_eq!(chunks2.len(), 1); + assert_eq!(chunks2[0].data, b"data2"); + } + + #[test] + fn test_large_chunk() { + let pool = create_test_pool(); + let manager = BlobManager::new(pool); + let ctx = manager.connect(); + + // 1MB chunk + let large_data: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); + let body_id = "rs_large.request"; + + ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap(); + + let chunks = ctx.get_chunks(body_id).unwrap(); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].data, large_data); + assert_eq!(ctx.get_body_size(body_id).unwrap(), 1024 * 1024); + } +} diff --git a/src-tauri/yaak-models/src/commands.rs b/src-tauri/yaak-models/src/commands.rs index b16fb2e6..f7bab2e1 100644 --- a/src-tauri/yaak-models/src/commands.rs +++ b/src-tauri/yaak-models/src/commands.rs @@ -1,3 +1,4 @@ +use crate::blob_manager::BlobManagerExt; use crate::error::Error::GenericError; use crate::error::Result; use crate::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent}; @@ -8,6 +9,7 @@ use tauri::{AppHandle, Runtime, WebviewWindow}; #[tauri::command] pub(crate) fn upsert(window: WebviewWindow, model: AnyModel) -> Result { let db = window.db(); + let blobs = window.blob_manager(); let source = &UpdateSource::from_window(&window); let id = match model { AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id, @@ -15,7 +17,7 @@ pub(crate) fn upsert(window: WebviewWindow, model: AnyModel) -> R AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id, AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id, AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id, - AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source)?.id, + AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id, AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id, AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id, AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id, @@ -30,6 +32,7 @@ pub(crate) fn upsert(window: WebviewWindow, model: AnyModel) -> R #[tauri::command] pub(crate) fn delete(window: WebviewWindow, model: AnyModel) -> Result { + let blobs = window.blob_manager(); // Use transaction for deletions because it might recurse window.with_tx(|tx| { let source = &UpdateSource::from_window(&window); @@ -40,7 +43,7 @@ pub(crate) fn delete(window: WebviewWindow, model: AnyModel) -> R AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id, AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id, AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id, - AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source)?.id, + AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id, AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id, AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id, AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id, diff --git a/src-tauri/yaak-models/src/db_context.rs b/src-tauri/yaak-models/src/db_context.rs index f20795ea..31479ccf 100644 --- a/src-tauri/yaak-models/src/db_context.rs +++ b/src-tauri/yaak-models/src/db_context.rs @@ -67,7 +67,7 @@ impl<'a> DbContext<'a> { .expect("Failed to run find on DB") } - pub fn find_all<'s, M>(&self) -> Result> + pub(crate) fn find_all<'s, M>(&self) -> Result> where M: Into + Clone + UpsertModelInfo, { @@ -82,7 +82,7 @@ impl<'a> DbContext<'a> { Ok(items.map(|v| v.unwrap()).collect()) } - pub fn find_many<'s, M>( + pub(crate) fn find_many<'s, M>( &self, col: impl IntoColumnRef, value: impl Into, @@ -115,7 +115,7 @@ impl<'a> DbContext<'a> { Ok(items.map(|v| v.unwrap()).collect()) } - pub fn upsert(&self, model: &M, source: &UpdateSource) -> Result + pub(crate) fn upsert(&self, model: &M, source: &UpdateSource) -> Result where M: Into + From + UpsertModelInfo + Clone, { diff --git a/src-tauri/yaak-models/src/lib.rs b/src-tauri/yaak-models/src/lib.rs index b41fb4d5..7af9be14 100644 --- a/src-tauri/yaak-models/src/lib.rs +++ b/src-tauri/yaak-models/src/lib.rs @@ -1,3 +1,4 @@ +use crate::blob_manager::{BlobManager, migrate_blob_db}; use crate::commands::*; use crate::migrate::migrate_db; use crate::query_manager::QueryManager; @@ -14,6 +15,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; mod commands; +pub mod blob_manager; mod connection_or_tx; pub mod db_context; pub mod error; @@ -50,7 +52,9 @@ pub fn init() -> TauriPlugin { create_dir_all(app_path.clone()).expect("Problem creating App directory!"); let db_file_path = app_path.join("db.sqlite"); + let blob_db_file_path = app_path.join("blobs.sqlite"); + // Main database pool let manager = SqliteConnectionManager::file(db_file_path); let pool = Pool::builder() .max_size(100) // Up from 10 (just in case) @@ -68,7 +72,26 @@ pub fn init() -> TauriPlugin { return Err(Box::from(e.to_string())); }; + // Blob database pool + let blob_manager = SqliteConnectionManager::file(blob_db_file_path); + let blob_pool = Pool::builder() + .max_size(50) + .connection_timeout(Duration::from_secs(10)) + .build(blob_manager) + .unwrap(); + + if let Err(e) = migrate_blob_db(&blob_pool) { + error!("Failed to run blob database migration {e:?}"); + app_handle + .dialog() + .message(e.to_string()) + .kind(MessageDialogKind::Error) + .blocking_show(); + return Err(Box::from(e.to_string())); + }; + app_handle.manage(SqliteConnection::new(pool.clone())); + app_handle.manage(BlobManager::new(blob_pool)); { let (tx, rx) = mpsc::channel(); diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index 012a40fd..6ec3ef67 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -1322,13 +1322,14 @@ pub struct HttpResponse { pub request_id: String, pub body_path: Option, - pub content_length: Option, - pub content_length_compressed: Option, + pub content_length: Option, + pub content_length_compressed: Option, pub elapsed: i32, pub elapsed_headers: i32, pub error: Option, pub headers: Vec, pub remote_addr: Option, + pub request_content_length: Option, pub request_headers: Vec, pub status: i32, pub status_reason: Option, @@ -1382,6 +1383,7 @@ impl UpsertModelInfo for HttpResponse { (StatusReason, self.status_reason.into()), (Url, self.url.into()), (Version, self.version.into()), + (RequestContentLength, self.request_content_length.into()), ]) } @@ -1396,6 +1398,7 @@ impl UpsertModelInfo for HttpResponse { HttpResponseIden::Error, HttpResponseIden::Headers, HttpResponseIden::RemoteAddr, + HttpResponseIden::RequestContentLength, HttpResponseIden::RequestHeaders, HttpResponseIden::State, HttpResponseIden::Status, @@ -1431,6 +1434,7 @@ impl UpsertModelInfo for HttpResponse { state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), body_path: r.get("body_path")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), + request_content_length: r.get("request_content_length").unwrap_or_default(), request_headers: serde_json::from_str( r.get::<_, String>("request_headers").unwrap_or_default().as_str(), ) diff --git a/src-tauri/yaak-models/src/queries/http_response_events.rs b/src-tauri/yaak-models/src/queries/http_response_events.rs new file mode 100644 index 00000000..145ea444 --- /dev/null +++ b/src-tauri/yaak-models/src/queries/http_response_events.rs @@ -0,0 +1,18 @@ +use crate::db_context::DbContext; +use crate::error::Result; +use crate::models::{HttpResponseEvent, HttpResponseEventIden}; +use crate::util::UpdateSource; + +impl<'a> DbContext<'a> { + pub fn list_http_response_events(&self, response_id: &str) -> Result> { + self.find_many(HttpResponseEventIden::ResponseId, response_id, None) + } + + pub fn upsert_http_response_event( + &self, + http_response_event: &HttpResponseEvent, + source: &UpdateSource, + ) -> Result { + self.upsert(http_response_event, source) + } +} diff --git a/src-tauri/yaak-models/src/queries/http_responses.rs b/src-tauri/yaak-models/src/queries/http_responses.rs index 4647446d..a555b276 100644 --- a/src-tauri/yaak-models/src/queries/http_responses.rs +++ b/src-tauri/yaak-models/src/queries/http_responses.rs @@ -1,3 +1,4 @@ +use crate::blob_manager::BlobManager; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState}; @@ -58,6 +59,7 @@ impl<'a> DbContext<'a> { &self, http_response: &HttpResponse, source: &UpdateSource, + blob_manager: &BlobManager, ) -> Result { // Delete the body file if it exists if let Some(p) = http_response.body_path.clone() { @@ -66,6 +68,13 @@ impl<'a> DbContext<'a> { }; } + // Delete request body blobs (pattern: {response_id}.request) + let blob_ctx = blob_manager.connect(); + let body_id = format!("{}.request", http_response.id); + if let Err(e) = blob_ctx.delete_chunks(&body_id) { + error!("Failed to delete request body blobs: {}", e); + } + Ok(self.delete(http_response, source)?) } @@ -73,12 +82,13 @@ impl<'a> DbContext<'a> { &self, http_response: &HttpResponse, source: &UpdateSource, + blob_manager: &BlobManager, ) -> Result { let responses = self.list_http_responses_for_request(&http_response.request_id, None)?; for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) { debug!("Deleting old HTTP response {}", http_response.id); - self.delete_http_response(&m, source)?; + self.delete_http_response(&m, source, blob_manager)?; } self.upsert(http_response, source) diff --git a/src-tauri/yaak-models/src/queries/mod.rs b/src-tauri/yaak-models/src/queries/mod.rs index 983ac621..fd1553d9 100644 --- a/src-tauri/yaak-models/src/queries/mod.rs +++ b/src-tauri/yaak-models/src/queries/mod.rs @@ -8,6 +8,7 @@ mod grpc_connections; mod grpc_events; mod grpc_requests; mod http_requests; +mod http_response_events; mod http_responses; mod key_values; mod plugin_key_values; diff --git a/src-tauri/yaak-plugins/bindings/gen_models.ts b/src-tauri/yaak-plugins/bindings/gen_models.ts index ebe00460..454903fe 100644 --- a/src-tauri/yaak-plugins/bindings/gen_models.ts +++ b/src-tauri/yaak-plugins/bindings/gen_models.ts @@ -12,7 +12,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-tauri/yaak-templates/pkg/yaak_templates.d.ts b/src-tauri/yaak-templates/pkg/yaak_templates.d.ts index aed6c395..5d24deef 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates.d.ts +++ b/src-tauri/yaak-templates/pkg/yaak_templates.d.ts @@ -1,5 +1,5 @@ /* tslint:disable */ /* eslint-disable */ export function unescape_template(template: string): any; -export function parse_template(template: string): any; export function escape_template(template: string): any; +export function parse_template(template: string): any; diff --git a/src-tauri/yaak-templates/pkg/yaak_templates.js b/src-tauri/yaak-templates/pkg/yaak_templates.js index 7ca3c562..8d2a7738 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates.js +++ b/src-tauri/yaak-templates/pkg/yaak_templates.js @@ -1,4 +1,5 @@ import * as wasm from "./yaak_templates_bg.wasm"; export * from "./yaak_templates_bg.js"; import { __wbg_set_wasm } from "./yaak_templates_bg.js"; -__wbg_set_wasm(wasm); \ No newline at end of file +__wbg_set_wasm(wasm); +wasm.__wbindgen_start(); diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.js b/src-tauri/yaak-templates/pkg/yaak_templates_bg.js index 98b3c8f5..4d11efa6 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates_bg.js +++ b/src-tauri/yaak-templates/pkg/yaak_templates_bg.js @@ -4,35 +4,6 @@ export function __wbg_set_wasm(val) { } -const heap = new Array(128).fill(undefined); - -heap.push(undefined, null, true, false); - -let heap_next = heap.length; - -function addHeapObject(obj) { - if (heap_next === heap.length) heap.push(heap.length + 1); - const idx = heap_next; - heap_next = heap[idx]; - - heap[idx] = obj; - return idx; -} - -function getObject(idx) { return heap[idx]; } - -function dropObject(idx) { - if (idx < 132) return; - heap[idx] = heap_next; - heap_next = idx; -} - -function takeObject(idx) { - const ret = getObject(idx); - dropObject(idx); - return ret; -} - function debugString(val) { // primitive types const type = typeof val; @@ -184,48 +155,24 @@ function getStringFromWasm0(ptr, len) { ptr = ptr >>> 0; return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); } + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_export_2.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} /** * @param {string} template * @returns {any} */ export function unescape_template(template) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); - const len0 = WASM_VECTOR_LEN; - wasm.unescape_template(retptr, ptr0, len0); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); - } -} - -/** - * @param {string} template - * @returns {any} - */ -export function parse_template(template) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); - const len0 = WASM_VECTOR_LEN; - wasm.parse_template(retptr, ptr0, len0); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); + const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.unescape_template(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); } + return takeFromExternrefTable0(ret[0]); } /** @@ -233,61 +180,69 @@ export function parse_template(template) { * @returns {any} */ export function escape_template(template) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(template, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); - const len0 = WASM_VECTOR_LEN; - wasm.escape_template(retptr, ptr0, len0); - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); - if (r2) { - throw takeObject(r1); - } - return takeObject(r0); - } finally { - wasm.__wbindgen_add_to_stack_pointer(16); + const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.escape_template(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {string} template + * @returns {any} + */ +export function parse_template(template) { + const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.parse_template(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); } export function __wbg_new_405e22f390576ce2() { const ret = new Object(); - return addHeapObject(ret); + return ret; }; export function __wbg_new_78feb108b6472713() { const ret = new Array(); - return addHeapObject(ret); + return ret; }; export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) { - getObject(arg0)[arg1 >>> 0] = takeObject(arg2); + arg0[arg1 >>> 0] = arg2; }; export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) { - getObject(arg0)[takeObject(arg1)] = takeObject(arg2); + arg0[arg1] = arg2; }; export function __wbindgen_debug_string(arg0, arg1) { - const ret = debugString(getObject(arg1)); - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export_0, wasm.__wbindgen_export_1); + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len1 = WASM_VECTOR_LEN; getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }; -export function __wbindgen_object_clone_ref(arg0) { - const ret = getObject(arg0); - return addHeapObject(ret); -}; - -export function __wbindgen_object_drop_ref(arg0) { - takeObject(arg0); +export function __wbindgen_init_externref_table() { + const table = wasm.__wbindgen_export_2; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + ; }; export function __wbindgen_string_new(arg0, arg1) { const ret = getStringFromWasm0(arg0, arg1); - return addHeapObject(ret); + return ret; }; export function __wbindgen_throw(arg0, arg1) { diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm index dceb16999aeedecf064a42a94887d72b1c76f1b0..dfa7764dff420bc633b4f857ec6680244403f30d 100644 GIT binary patch delta 19196 zcmch93vgW3ndUwH(xWA-g)KjY-I5>37&-U-Xba;u*ydqlV>>41)xJ-4%dKwF-Lfq( zT!)a16A}`0!R8SgOmKLEi69UrA>N(I?q-rL$R@R$+Dc}b+U!(zE1R9!ovod%rE2y& z_jXI&Ey*^?%s|oi+;h+Q&wu{+|DPjHyc+)aWJF{1@+e^#hWt6{J-{#r7~Zar~+k;jrnq}4l5mU|BAA-qzL@jmBd57>P&QVx6&Ath2K- zI**C9McUiK;m&BZD~c)Mw$4}_|FlKhBACjwzRaw z!ySpv&YsSB3+B%Y$K$c~a6A%=h5;vv^}^wBEZ*K0BP15&a+-XR+&Q@K72=VDe?r_%M;>_a{`)2#B1aEDOr9f; zlUvt~eudn<{`RxPexAHZj*%OM^n2v%t3Y{cz&fD9ynr!gy>sl5$M>>f2OwYR5af-m0_ZM9nzC;o$+p#DRT`-ck z!{ZW5$T{zp#0Y-Bn;2W5^>=rI(0$P)Nx$>x1vC@dWUBk$gs{W#g7p~1FSv&!y%P%t z$ztz^3vNTZyQdHRg8%#3o&mDcd$4B*8S{SHvl#vVrDroJH?Ytp8SkZqSx~ln(I>5B z@8rDXziY$TzgqiK;!M7A^@lM$9^_smq|;j?>|E_I@#MT@XEL0A>1p6kUptZ=>F`K$ ze)`F3&uiY(!f&MSlVm*Ins}A;cXpCwG@Td$L0gmY#OLug5L(a|qK`yhq@y*C&x`Rn zp?lvEZzuX>yY#n|*`LU(!bJD_QjcPv=Tl3_M(;0D`&NMbpf*Xr4+^FuiQ4N2^fjE& zyz4gnC25^pK6C?V*E&O>X>tVY%U}DmIw)(s&uww)ED>yX?WX@48e_cYHuhkR ztJBwz72fUXquBZ{((i`J&dINC`5o!_J^>3NfJYLqdPn#0WRdsgo+XnXZ95$%k9a5c zX+ZgZ|+>ZB%h2XzD+={R%|dy212d$rz3IF`|wb2)c@+u+qDpL zR_~&7KD%r6{;4^XDD`syXCTxIF~DPSn%vtNPR7zFU?)I^U{adtBOUE=AWGN#B{Sm7 zJ|zsWe%AZ>E_VA#AIf&nA5d-tlr5bxu;yWMQ)e4^u)m{?3UkQsfDkA4gFSsAA7n>M z(MF9bQr?i;AF()-E2ELrJ^xBs5>O|JZ0gpfXOVE+g=%*4~roxx7lr$53Y zLEk?P-oNo|^}XS{_hG3r-BZjBOJfYoUI)A(#IK|A^H1%;_L=vw~ zhO);9d1UenxuG!DzL5V3SrTJ9!ySwdvo%Qpgpw`6k}niC0Gp2sOWN9*J{XbK`2Y4o z#j8kU^6FwMX^qFRRygt9$<$aEAuo70-P;%c9s#$$@f6r}*W~!UzaymGGs>_N-izf| zRy&d>`6>H$Dog8ixX9@9|zd)OA@3 z|2K!0U|#!SIxls2Rri#9L*YT8M8Yc`URE^@Uq4L8zI=FXZ@nUhn^orbhpz^(B}e+f z;GrXntJ1#z$o_!9Q79(}?0s-#J*?n=Ke83S;>0!h)hF&)yaTo(IRYN^(yii|q)#}* zpHF-QeR$!%8v-b?fKA@t-B-j+>;CkWbY|7JE~c)v9S$!tl0NQ%#5=rydH<~#*L`$8 zBEQu~-vwuXeRNCJkVc`Y)R3-!@E+3ZeeFRW3tf0{8QC`Zs|OEetPnb4-iADsUA`0!JJs+Vbnr70BOq(J63ptByqn-v$$T^zW{4tjrvSZ|y zW&N?fwvUR*wokf9e@oik+8Opo5)5mB1W~0)Qk}p~lPz*+vA^MTBnk0PyNC~NP6FZ9G{Ks*)zKG* zfo@60`l57Ms!YIlXD28WPPPCXHOyp024h?B>;pd7+Du)41$4b_q<@zB7c zKE;v|nUbnTIyzKpJmc@`U$49vcXKiFk{>>%A?HttMA`pZ6A%7rlocW1$2wnEVg%zTf+; z*IPe0OO)!sr@iKZzb3bM|LXCT5Xk@b_-P2|@h6;~?~~A`bO+TT(hnx5Bk7LBTVD9& z0pM?)>|fc=z#@Vl$p|%0)Dn^oaG)jq)Z_&w9ZI&rM!tOVR@g`y4I%JJpA0_GSSYY( z-KW+A*rroUT-dW14T^l~#p35eBuvGe^~IdZ8E^sTfBOC=V1mYPAia=zD!phXeV@V5 zkcxDNAzO&O`UxJZM%IQHh4v(wwV_@m42eP@LAAsOMnwwkq#N>C3V;v^EP^g(ZMe0n zEJ?C99Ox8fFbf8E(FWEYzymXS@i_m?1$01ksg5Plv3Ml?9{Lhvh!~kPf`tG|fD46< zK}oDjCo2F#rGD{9VjPb~$iJKg^2Y&WZ}`*%*!5SZ?(FXEV|*P<|MuhsCVh3{l(*x_ zLDQJmNL>EGs1X5&reUHST9rmT z2|fpU6Q%wgpu%7mlqLeI!bSpvU(ig!@s>aHWwPCS=@}}QKYb_US8|1e(5^2?3Z?230oFu z2;5BieQNW7Dg-sKc^|%Xq&nl?m+6eRUe4lo=__aZ7Is}C&h~!w%672-*sI&)FPy#r zHbavW^nITT38)c_#P0;gzx9`A>5rU8qbUyAv?=a&Q4hVwFdO6MOVOTFk@1AHZ*)UG~C zBj41J_|**E1qA2>-34vqL4}I;z3OG&QpmgBskhdlvKL<$NOIy}e+Y^Z@`v0&S&A2G zfbi2=g*@xn>p#WVtxaRy8sI@7=C24lfkq%r&;`oW6cYtf;S>|0xPfQ!0bc|tb9{q9 znKZ>NzlC+9<{Z@F*^_JMQ@4_& zwD7ftitbz9*~$K0bwu+?*2ctp8J}^`O+OI>hgzPmuUhz)un?eh8&L*wi0NUzrRo>yCQM-pPgb$Gze^JqSX-`Ob<} zi$kdH7O7+Z>lrg(An)zhh|VM^+_1?RJ;8iYR8gf$j9Z1t~ec#_yCH8S%E&y3dBb! z0E2ML`0i?VNjyQRzKRB)t0_((pbr`}`)pTyo>oA zt=@mJfPeKsq@a)5%!2t7x_)r#Xra9RFg*qO>kKv^(`Kf6_>xL_9O=QvkF=CsQEk`P;RN2OlcYrK)G250Uh2=PdRTa)u9XpgBDQC(P zCFpQawA?TJOskWkWEN{AY>&8YN zrBeQRP$`w#MbCxs49G^S+0gB8fDi%_yl71Lcvs*vBmmc;F z?Y)K~Te7j~Lw2TQ(~$sSst_oN7fj8PRnb;D)XO3mX0keKi4bF6(QH$&_0C<*m=C+n zyA3eUl*b(>cLWsmH!}5_g^DR=sNNEgrNBP0D47oaveZ%pOvL8OR9aFqOK#=yMIyGSyG{pr|sOuqdjmF4~T| zV(U!Vf|;G7pqn!DMQZl1rw>4?zGihBsxQg9C_5}`i&9dkN~5nAFeA_l2#o&(KJXb> z-7@}r1AoHA8u zDikw82ZUq0e&58NO0lF5!;fGa^ft+q6_XT!w=~IOMczP|pspO6LJR9pdCwafe5MVr znHvV7voGHyvV;O5Id#K{lLX9&@w00AuLthG=V|sUS{ScWa%ED2&6~z?;_eukOOfy_qIB~Evy$zC4u_zA zaQn5uM=w~JEPnFTS?IPVNeZWev>=JC58hnU=UPO$$B!(h6gt4A(InDP0h%=jFYo{{;RC2nXV#hs;cv<#_HTZ$Eq5uiMqscEJ8WeQe20>3@HKf z7ZVcJ(pWfN$5B*X6%Ab|4EZ*5OotUY=rh)DR>cZL4B{FP$HmuO`b55{Thk)s52g+m z)2kmr!HRy+&KN}m#aU5PB-4hHTD-`b2xvFgsYhMR6y!dY@v*!eke$P$kn_WOg*6a5 z!T*^ENo<&UX>+k)!Wh_jL<-}D@a734$WahGBAou1H7{FLO6$5DUP{6)MG{##Hp?GQ#?Cn}&o4UyC4`B_ZI3rx0b` zbRDGL+KgptVb88=3JPa$W~-rg+zCn(Pj{$b>U~0G2fcl{F!Gy37h1FALXQV(Rov zOVb2F;91SI43kxzP1iMwz?68vZvt1R8s_|4bPF0swZO6w?7nyOhSQ_&bx(`8jr+3UNS^6eXD z-lE-d?M(i;Fmcmn@&5a(6nfwRV2X576m)|%9E+0`TiV)mQ+M0?KuAH*@jSLRUN~IR z$1;wd%hS{>E6bK48@#3?|A5AcHySgE>QWm!jlh&$o{g$Co7G^lG)d_->n2+f~OkU+NgGdCiV$A_+hU%dHp^2O=sD@$7x7{4fuI7EX7jMJa`fo6Qc=1Kc zj}@yPx`dKZZj{D}s44K0MYCO#Q(48@eVMRa1fDa1!F+NGFN+2$ZyG0xdoB~$Y_K#7 zq*Xc5aClzU1#Rz*)5%w=JmJC??U_I=W0b1BsW<;V~5&}{5x)}$-kd7AYt&G#k4srj)7>@DW+04 z;1?7MUXc6?Dl!E_U{&6fBEfFOWkt@q`yyz#bqaGiXxvkE=RuTMT|Q5X zOcRJrq11<|>77jwO^%h3X=@xW$u=v?nb@d3YPzFi2L@+UC7Z=4uo?H+rJ`-=>Bb5+ zf@yrt8M%C}f)fr?AJa{=GDXzXoFF&1SrqCG)MHTDkaSTqrU~NC#hARiDD&FSd?GE~);4A!!s5+)pleQ>6iYjkpZ zDmn@n5Jlt`?x)N=B#A{`iFGu=kXfEbjqmboCXPOAo8pPH4pVkyYj~|c(M4UiF-gLf z1YSp6tBV$@l9DBJ+3V}1v@X4AED0a>%a3FD<0X1x@MB6fKRQyrk5p!bIj!jhq zsHnKjvH}}{YOQ1lFhr)sIU~2unhN{=YmTg@Ay5)|)-iErq@$k8a|U;?5gzhC`qx*e z<6k6Ax+uWBf)r+Es_yfcW(*nBEJt9lvKFVw2yx-#IiBNGadZa+#f7#u*_5M&<%tb8w~i|EE1W(=(DD+BE7)T z8w&UEkoWe@IP^%4BE-n~M|H_^HaA*Y(G-?84{*!N{RCzz4QU=wnd3NO>R1uw4G0w?t!nyt-w!OSpK~{65mSGg2alcCjg2&; z%nG)`t16GWl7V`mB_5jPkJ1ibBpq=Z@)0o;q$9ioUDY@NhYCE4^30KoKxhCUQ~|IE zTuc_{IkJOD6wYBT0Pf6`C_sTTImbdJl#^MRg)_ZxE(mi0KujkZ268G{;w6XE@1GTb z3+BxQK{5r@LRkkTcuqz^;^?dpE&@Q)IaSsXr>G9F(bNZK0&wZc&)g^=GM8y20V<#j zDM$~_gm7^HhGnyY;((CI_!NnEn*hN7`^Ok_qkytI$8s|E;HV;spvMRfNa@MuEVY4| z`Q4?mE0+qf;)tkYSsbe=8cn7?Pqd5r_#hmB#l&2hLw?62{*XW;7`KN=(+=m{AWvnL z-kRVHL>0V6y#)&2yz~Xa53TVJf`X0u)~8x@2D*VUQY1&`VgJBhglV?)@MW*dRdvLn zbzql6x`fj~1Lq@OB-JwBER?PKF4%L#k1zoKlhTft9ToYp##)l@7_9syGCZ_qR*yi7 zw|i>Uu3FjFh35!%i8=M(0UJHll5N?(7fT6-;Y)yyl$zf3_!FW9Q~iM0884WXToJBp zYk^h`X(*P@qqahuS=Ql^!J0^uP~$LF?KoNEy9T%ZFnZRo@5^N0obpy%K3I3>7=6}4 z?y2B?Tn$Vm%33(_mRM9S1i?|&uTXOP=l`FE-Uwq9heK^iL%^mP&LiYA&2dF=crX=? zONN8nG=hel1ud2TYCW4bG*#E;`hNOZBbt>Frb$$yQk^>6(or`&??@E#>AFXD3X zX*g3Xj((kR!AWO8K)ry#IbEQ2*vbU{>^VoPCpcZ#Wt1H(lO8I|&NpVA<2FtbbRK6f zmWqs%({Q|df>cAd>b_9yEz*M5_Ubc->q)p4fuaD8`ZDm_{?a&0Lk8^d2ont)(hHA~ z9dn6*d&%gGDv5#nI-KD+CN4Yhxc}qL4e(RK+Y$hL{8Oj`LQ;X1a9AMYT8*eUPmopg z_Ltkl$&;T>kuOh={nV1>97TXZ=WW4K4MjRdc#xNen);@#F2D{#Y8V}={U&aP!4IHB z>>yDWo`e9>3tUZTKSkJCjAujOC<~}dDIypw+Z@kYPm{HCUrB2M|4jW@diBhuch729 z&zc5*lWeJ3gGR$ymw?keZ-#AW5l$h5Q61gT4BI|^5r;q>Sl|&f&5>baaCOe&IZ?9D zP*j_)+ca1>8vi1Tqm`Ct$;hX%+I7sP7f7b9dZX3$&)&c~$y7v(wNa=<7J}IBxql?O zoF$nd_Yyw8KG|%{HD`6Jx{B(L>hQLKD}D+mJx`pAr?YV2b1&g15;G3cf;u>|LJ=q3 zxRanl6?y$z7h&=gj}edBqJFQne@xj>RnF%4rSIbWEXe)98`Q*oSU)MOJwy$#LiE;e6qZJX+`Bs zTp+V>FG6s1g|~!P$j16&M=eEk>n{WOwbm(mi3tBKo;PtD#(=hJuac`Sf?V(^s1UzK z)?enxj2je&UMJG!&hV4%TGjsza&=>(g?t!?LV?xOG*iOWSlz<)M+HUVH_6PC8P}(0 z4cw=fM;%VkH?g>9DDyCkh}Bs*PwD*Z^lm1%JAEwEOz*cy?b=Y)0as%a|7|Kl!38>% z(_xBDUKLOx{SFbJbhDh+HlBQFrmiF96mW)&YZsg~NiI=^6>Y&VZDjAZhQm*rf1BJ~ zm8T%--Ru_-cZ^qRlGU)&;MT{KXdxZ~+PJx_x-#-^U4Dm#lvDpZo2mSq)>|86r0nw6@qP2dihe`X(up+2J;n?(YQ0}`9Fag$Y0HR*k_k4uhlPt|7@TIed{^aWtqoG z7pP3{XE?TAp-0TE>*B_E{@@^r?8VZ=x~{sH!QxnD-9nn34Wj6lE8$NG25Ge%HxP%{ zwNF>j)^#C4rZ>X}hx0{4&zINDFBA(9@Ubx{)u1z0p+s2MwFD(syJREL8>Hz&d0lH6 K(flBk?*9XW4V7&G delta 5562 zcmb_g3vg6bnm+%1_2YKax5@iKa=Vj22qY%ql>~%bgs2P1OM^U<5E@BLNJs)n6zL2P z2bmQihesfUcZ4BekcJg-baWkebjMwF#&Kqy9swK3VAEK1~r3$4>Oa@uf?te=n6_YHULk4M> zW$^ncfykb1kIlx}CrSMGG@KN{#^a=vup*zOGo;AF+=@?AHO-|dUdgFBREI;;G(}NW zMV6h4rn*&Cb-Ue)u4=MFlO(sIcoZ;5np<@`otmP_V9+#>Ne=N(RUE3UI5l0u7B&^9 z%jQr;oIyMv8K!+v^A?%Rkbx$wYJsNK3vt@RJXP*)WQyt z>j7dG>7~192R+nB8+X&D2lMYgOh2GCUDQ5p-TJnc($$GX9Wv15M@Eyc^^a zFN3yu{M4|0uw|+*C$mh@d!t$ey>})CDObwICK)|^voFUi)4!%5K|w<($V2?~gfZH7 zVOAsm)K^Mnyx7Qc!xWewUvDhpRYnT+@J^#1$FHCO|8aairSLz*&&PaPLIC?U_P8Y>m*()Z33E~#nXn)BHV0iX%iBzs zZFyYcbhx-OF-*(&yNPQMf@w)#IB7v-iurRF*nN3#P;KPByf-zemht&_B=F1mVh%Gqz27pfD5sW*Ew3qCrw`(yV(BqGmRxVfSv|3x0sh*!562IZsasCt z2nD@xlxi9Eh=7xrAEfzoSFa~i>aEmA(*@ z$ri2i%BE)RMty-_qD&SG5ah#hVZ<#u2s)(sg|nZurOZY^AmtuNnd#LKs;zXd*M*=k zkITlEy@8xC5OOsj*&;oTZyfPW^D}Y(c?)v9xgy?sqqaQB-YiGFrzTC%7h2+iyuiIRiaJfL3`rT3S%-i{$PTK#Uau%Rq3EXzQA z-dnZ;PMEwrlPdVqQT;aoaqRPin_0A_z*r z0az%;kw_6b4sDHGsGA}~_a7U6Mky-u${uFRbeckdWICcMf3bEltnoFaxEw5iYU0%Y z!i$re{9qKrhRn!(A1N^nIstX=qNxT9MM^UaaHG7jJrFRB+o*1Fzey{ba735P4PuHl^=Nv8Su^{ zGon(oXn=V3qfOx4^QbjKaK@T*vgpljG)bl2+RowLS^Sen?*pB+F^)3%0~?=1OudiI zjMY*VxhZPticKqM1b<;uF%D!C#ahY z{tu{zq4bb5zQ!ZdU8KZ+8U|;!HY5_^312+ zr+fI9Pi4WTtX)5qsFWYu^*Zh0Z|upXFn^z~!Lzh#Z!#U^HG2x-O$}i?hc)Aad(wx{ z-Nj4y=Fr3#ZO7jKM$13g`!fH}r!uv_^a%s_Yy0|e-_`q9Ck&EQZh1slC`dq^VtI^y zetmy6P&K_LnB`zdFBxc81ky!Xkq6gySO+2~IFji?!hOHzJ|vuIv1q5(zUYDIu+ilB zV^0BuWgJKuhUCyht2OJhQ5AW^!A^BVfO#ao^WX%Y*&FnWl_);e@Ag4@p0SfJ=*Jf z8kH#1_$p|o_^^N~s=au%$SC@vu6=N5G~({*%SL%B>N7De=nq3U>n7|zr zJWqfjxZ@r1=&I#eho7Lo;pYw`r2Or}Rgh71Bq_0ne0CxW4Gd~wk5IDW$awp0Cagob zU3YY(4B7lohs*!Be4^g!*NCKW7PHKUgK9RD$`M#HYWTB9P5kxp^P>|nx{giqDZ<6k zd_f)9%;z2(sq~^8@q**2zB9i(lQ0J`z(2Hrt^D}0g@EY5@f6(0I9`Eq(ea@>CjaPo z9&*DtF%2(X5j$BVIW8!*1tp`K*Pa;ZJ1&Zyc($Ar1#c_wIS~f>l26`EgS`4=7CHI0 zldG}w@yTRdT{EYdbFg>0-w_dmyWVaic$lhJPFRBm^+@ z^y4&zfBf{=+$p#$5ygBZBaT1?T5FL=p^z`Yw z(z+=ii%gJ&x9q8b_a?xGa?Ca9M2i3gh@r` zM)UrGX_$R9FlPjkOA-OV6P#IBMH0dsfGwJYbI*3fIR1QAa1@-t*b~pU<~LRmHPa z?rgL)SjY@FAADBmPrguyqW9+ce7@mAVMeU)A*TR7%Uw~%Vv#`_qwW3V!kTEgatEcA}+;_2?()jg@xz4rG z3*E>4ml6`5!25V{}tc_L2@$WqqWr5;(eu{IH4*kuM+=8pN{(TJRHq zkQJ6-+Fzlwshm-b{|`h7x04heVwbHi?sin)B#QqoM6T~bH1a!9Ir)v}M#qVYXbQ)Q zklJYI=XsZd{vpn_ub}bem(zn!3t0XY@7iQ4Z@7{!0Ip}Cav10N%a1axN$1OwxVS2FB^-gqU=F3>iefEI#FM!$%%WULl~(}mzv2p0SnTErVKmIozk zgej%@75>7NIzWm0uO^_kO}(1wju#RHz$N~@tC{m0Ocy%>m0*uyQf0c|-UJryzxfQ@ zF}f4mCb1JW#ugUmv(JrZdH(ZhiBW1>UXLle!4&TSsU(uYBk^~GK!f+{q+ZPCW}&_P|%>GA~M&E3bp z-gR)#kJ7?X1{iG|gLlCz*{)8t?XZrbD8lh0#EPu*SD_=>NY$NDxsvX{#F)WsMHUn3 zYws*&x{9Fqbd4DoAXdgyVTK|G6)V{PSs7|>S6AfXkFINhUl3y>8DqaAgQes9ORQ6d ztbfD*?v+vaRpHtzX>=~~*(>{rj`QwUvl0qHmh0BT6;}H%lcNWu%LKc!8lJlev-|P{oogey@H~`LNm8`eBp#pZSl(SqyG!P!3CQD diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts index 55be67ff..d8bbabb6 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts +++ b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts @@ -1,9 +1,11 @@ /* tslint:disable */ /* eslint-disable */ export const memory: WebAssembly.Memory; -export const escape_template: (a: number, b: number, c: number) => void; -export const parse_template: (a: number, b: number, c: number) => void; -export const unescape_template: (a: number, b: number, c: number) => void; -export const __wbindgen_export_0: (a: number, b: number) => number; -export const __wbindgen_export_1: (a: number, b: number, c: number, d: number) => number; -export const __wbindgen_add_to_stack_pointer: (a: number) => number; +export const escape_template: (a: number, b: number) => [number, number, number]; +export const parse_template: (a: number, b: number) => [number, number, number]; +export const unescape_template: (a: number, b: number) => [number, number, number]; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_export_2: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_start: () => void; diff --git a/src-web/components/ConfirmLargeResponseRequest.tsx b/src-web/components/ConfirmLargeResponseRequest.tsx new file mode 100644 index 00000000..80e9fb30 --- /dev/null +++ b/src-web/components/ConfirmLargeResponseRequest.tsx @@ -0,0 +1,58 @@ +import type { HttpResponse } from '@yaakapp-internal/models'; +import { type ReactNode, useMemo } from 'react'; +import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody'; +import { useToggle } from '../hooks/useToggle'; +import { isProbablyTextContentType } from '../lib/contentType'; +import { getContentTypeFromHeaders } from '../lib/model_util'; +import { CopyButton } from './CopyButton'; +import { Banner } from './core/Banner'; +import { Button } from './core/Button'; +import { InlineCode } from './core/InlineCode'; +import { SizeTag } from './core/SizeTag'; +import { HStack } from './core/Stacks'; + +interface Props { + children: ReactNode; + response: HttpResponse; +} + +const LARGE_BYTES = 2 * 1000 * 1000; + +export function ConfirmLargeResponseRequest({ children, response }: Props) { + const [showLargeResponse, toggleShowLargeResponse] = useToggle(); + const isProbablyText = useMemo(() => { + const contentType = getContentTypeFromHeaders(response.headers); + return isProbablyTextContentType(contentType); + }, [response.headers]); + + const contentLength = response.requestContentLength ?? 0; + const isLarge = contentLength > LARGE_BYTES; + if (!showLargeResponse && isLarge) { + return ( + +

+ Showing content over{' '} + + + {' '} + may impact performance +

+ + + {isProbablyText && ( + getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')} + /> + )} + + + ); + } + + return <>{children}; +} diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index a4d0d26e..f0dc6d03 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -10,6 +10,7 @@ import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { getMimeTypeFromContentType } from '../lib/contentType'; import { getContentTypeFromHeaders } from '../lib/model_util'; import { ConfirmLargeResponse } from './ConfirmLargeResponse'; +import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { CountBadge } from './core/CountBadge'; @@ -23,8 +24,9 @@ import type { TabItem } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs'; import { EmptyStateText } from './EmptyStateText'; import { ErrorBoundary } from './ErrorBoundary'; +import { HttpResponseTimeline } from './HttpResponseTimeline'; import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown'; -import { ResponseTimeline } from './ResponseEvents'; +import { RequestBodyViewer } from './RequestBodyViewer'; import { ResponseHeaders } from './ResponseHeaders'; import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; @@ -46,9 +48,10 @@ interface Props { } const TAB_BODY = 'body'; +const TAB_REQUEST = 'request'; const TAB_HEADERS = 'headers'; const TAB_INFO = 'info'; -const TAB_TIMELINE = 'events'; +const TAB_TIMELINE = 'timeline'; export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); @@ -76,6 +79,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ], }, }, + { + value: TAB_REQUEST, + label: 'Request', + rightSlot: + (activeResponse?.requestContentLength ?? 0) > 0 ? : null, + }, { value: TAB_HEADERS, label: 'Headers', @@ -98,11 +107,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ], [ activeResponse?.headers, + activeResponse?.requestContentLength, + activeResponse?.requestHeaders.length, mimeType, + responseEvents.data?.length, setViewMode, viewMode, - activeResponse?.requestHeaders.length, - responseEvents.data?.length, ], ); const activeTab = activeTabs?.[activeRequestId]; @@ -200,8 +210,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ) : activeResponse.state === 'closed' && - activeResponse.contentLength === 0 ? ( - Empty + (activeResponse.contentLength ?? 0) === 0 ? ( + Empty ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( ) : mimeType?.match(/^image\/svg/) ? ( @@ -227,6 +237,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { + + + + + @@ -234,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { - + diff --git a/src-web/components/ResponseEvents.tsx b/src-web/components/HttpResponseTimeline.tsx similarity index 93% rename from src-web/components/ResponseEvents.tsx rename to src-web/components/HttpResponseTimeline.tsx index 3a40404b..fe5fbde3 100644 --- a/src-web/components/ResponseEvents.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -5,7 +5,7 @@ import type { } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { format } from 'date-fns'; -import { Fragment, type ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { AutoScroller } from './core/AutoScroller'; import { Banner } from './core/Banner'; @@ -20,12 +20,8 @@ interface Props { response: HttpResponse; } -export function ResponseTimeline({ response }: Props) { - return ( - - - - ); +export function HttpResponseTimeline({ response }: Props) { + return ; } function Inner({ response }: Props) { @@ -252,20 +248,6 @@ type EventDisplay = { function getEventDisplay(event: HttpResponseEventData): EventDisplay { switch (event.type) { - case 'start_request': - return { - icon: 'info', - color: 'secondary', - label: 'Start', - summary: 'Request started', - }; - case 'end_request': - return { - icon: 'info', - color: 'secondary', - label: 'End', - summary: 'Request complete', - }; case 'setting': return { icon: 'settings', @@ -321,14 +303,14 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay { icon: 'info', color: 'secondary', label: 'Chunk', - summary: `${event.bytes} bytes sent`, + summary: `${formatBytes(event.bytes)} chunk sent`, }; case 'chunk_received': return { icon: 'info', color: 'secondary', label: 'Chunk', - summary: `${event.bytes} bytes received`, + summary: `${formatBytes(event.bytes)} chunk received`, }; default: return { diff --git a/src-web/components/RequestBodyViewer.tsx b/src-web/components/RequestBodyViewer.tsx new file mode 100644 index 00000000..215c5d25 --- /dev/null +++ b/src-web/components/RequestBodyViewer.tsx @@ -0,0 +1,52 @@ +import type { HttpResponse } from '@yaakapp-internal/models'; +import { useHttpRequestBody } from '../hooks/useHttpRequestBody'; +import { languageFromContentType } from '../lib/contentType'; +import { EmptyStateText } from './EmptyStateText'; +import { Editor } from './core/Editor/LazyEditor'; +import { LoadingIcon } from './core/LoadingIcon'; + +interface Props { + response: HttpResponse; +} + +export function RequestBodyViewer({ response }: Props) { + return ; +} + +function RequestBodyViewerInner({ response }: Props) { + const { data, isLoading, error } = useHttpRequestBody(response); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return Error loading request body: {error.message}; + } + + if (data?.bodyText == null || data.bodyText.length === 0) { + return No request body; + } + + const { bodyText } = data; + + // Try to detect language from content-type header that was sent + const contentTypeHeader = response.requestHeaders.find( + (h) => h.name.toLowerCase() === 'content-type', + ); + const contentType = contentTypeHeader?.value ?? null; + const language = languageFromContentType(contentType, bodyText); + + return ( + + ); +} diff --git a/src-web/hooks/useHttpRequestBody.ts b/src-web/hooks/useHttpRequestBody.ts new file mode 100644 index 00000000..31745ceb --- /dev/null +++ b/src-web/hooks/useHttpRequestBody.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import type { HttpResponse } from '@yaakapp-internal/models'; +import { invokeCmd } from '../lib/tauri'; + +export function useHttpRequestBody(response: HttpResponse | null) { + return useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: ['request_body', response?.id, response?.state, response?.requestContentLength], + enabled: (response?.requestContentLength ?? 0) > 0, + queryFn: async () => { + return getRequestBodyText(response); + }, + }); +} + +export async function getRequestBodyText(response: HttpResponse | null) { + if (response?.id == null) { + return null; + } + + const data = await invokeCmd('cmd_http_request_body', { + responseId: response.id, + }); + + if (data == null) { + return null; + } + + const body = new Uint8Array(data); + const bodyText = new TextDecoder('utf-8', { fatal: false }).decode(body); + return { body, bodyText }; +} diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 41d6a166..0c856176 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -25,6 +25,7 @@ type TauriCmd = | 'cmd_grpc_reflect' | 'cmd_grpc_request_actions' | 'cmd_http_request_actions' + | 'cmd_http_request_body' | 'cmd_http_response_body' | 'cmd_import_data' | 'cmd_install_plugin' From ba002740452133ed4a17df19f961ffbfcd2a7bf2 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 28 Dec 2025 08:41:56 -0800 Subject: [PATCH 13/17] Switch back to unbounded channel --- src-tauri/src/http_request.rs | 6 ++--- src-tauri/yaak-http/src/tee_reader.rs | 34 +++++++++------------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index c465e2e4..64230cbf 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -389,8 +389,8 @@ async fn execute_transaction( } Some(SendableBody::Stream(stream)) => { // Wrap stream with TeeReader to capture data as it's read - // Bounded channel with buffer size of 10 chunks (~10MB) provides backpressure - let (body_chunk_tx, body_chunk_rx) = tokio::sync::mpsc::channel::>(10); + // Use unbounded channel to ensure all data is captured without blocking the HTTP request + let (body_chunk_tx, body_chunk_rx) = tokio::sync::mpsc::unbounded_channel::>(); let tee_reader = TeeReader::new(stream, body_chunk_tx); let pinned: Pin> = Box::pin(tee_reader); @@ -584,7 +584,7 @@ async fn write_stream_chunks_to_db( workspace_id: &str, response_id: &str, update_source: &UpdateSource, - mut rx: tokio::sync::mpsc::Receiver>, + mut rx: tokio::sync::mpsc::UnboundedReceiver>, ) -> Result<()> { let mut buffer = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE); let mut chunk_index = 0; diff --git a/src-tauri/yaak-http/src/tee_reader.rs b/src-tauri/yaak-http/src/tee_reader.rs index b70372a2..2ee70088 100644 --- a/src-tauri/yaak-http/src/tee_reader.rs +++ b/src-tauri/yaak-http/src/tee_reader.rs @@ -6,14 +6,14 @@ use tokio::sync::mpsc; /// A reader that forwards all read data to a channel while also returning it to the caller. /// This allows capturing request body data as it's being sent. -/// Uses a bounded channel to provide backpressure if the receiver is slow. +/// Uses an unbounded channel to ensure all data is captured without blocking the request. pub struct TeeReader { inner: R, - tx: mpsc::Sender>, + tx: mpsc::UnboundedSender>, } impl TeeReader { - pub fn new(inner: R, tx: mpsc::Sender>) -> Self { + pub fn new(inner: R, tx: mpsc::UnboundedSender>) -> Self { Self { inner, tx } } } @@ -32,21 +32,9 @@ impl AsyncRead for TeeReader { if after_len > before_len { // Data was read, send a copy to the channel let data = buf.filled()[before_len..after_len].to_vec(); - // Use try_send to avoid blocking. If channel is full, we drop the data - // rather than blocking the HTTP request. This provides backpressure - // by slowing down the reader when the DB writer can't keep up. - match self.tx.try_send(data) { - Ok(_) => {} // Successfully sent - Err(mpsc::error::TrySendError::Full(_)) => { - // Channel is full - apply backpressure by returning Pending - // This will cause the reader to be polled again later - cx.waker().wake_by_ref(); - return Poll::Pending; - } - Err(mpsc::error::TrySendError::Closed(_)) => { - // Receiver dropped - continue without capturing - } - } + // Send to unbounded channel - this never blocks + // Ignore error if receiver is closed + let _ = self.tx.send(data); } Poll::Ready(Ok(())) } @@ -66,7 +54,7 @@ mod tests { async fn test_tee_reader_captures_all_data() { let data = b"Hello, World!"; let cursor = Cursor::new(data.to_vec()); - let (tx, mut rx) = mpsc::channel(10); + let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); let mut output = Vec::new(); @@ -87,7 +75,7 @@ mod tests { async fn test_tee_reader_with_chunked_reads() { let data = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let cursor = Cursor::new(data.to_vec()); - let (tx, mut rx) = mpsc::channel(10); + let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); @@ -117,7 +105,7 @@ mod tests { async fn test_tee_reader_empty_data() { let data: Vec = vec![]; let cursor = Cursor::new(data.clone()); - let (tx, mut rx) = mpsc::channel(10); + let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); let mut output = Vec::new(); @@ -134,7 +122,7 @@ mod tests { async fn test_tee_reader_works_when_receiver_dropped() { let data = b"Hello, World!"; let cursor = Cursor::new(data.to_vec()); - let (tx, rx) = mpsc::channel(10); + let (tx, rx) = mpsc::unbounded_channel(); // Drop the receiver before reading drop(rx); @@ -152,7 +140,7 @@ mod tests { // Test with 1MB of data let data: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); let cursor = Cursor::new(data.clone()); - let (tx, mut rx) = mpsc::channel(100); + let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); let mut output = Vec::new(); From 6869aa49ecbed693b574e56bd3c0b8eb41a45ce0 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 28 Dec 2025 08:43:13 -0800 Subject: [PATCH 14/17] Increase max size of multi-part viewer --- src-web/components/responseViewers/MultipartViewer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-web/components/responseViewers/MultipartViewer.tsx b/src-web/components/responseViewers/MultipartViewer.tsx index 38c873ae..22ae8d4c 100644 --- a/src-web/components/responseViewers/MultipartViewer.tsx +++ b/src-web/components/responseViewers/MultipartViewer.tsx @@ -23,7 +23,8 @@ export function MultipartViewer({ response }: Props) { const contentTypeHeader = getContentTypeFromHeaders(response.headers); const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown'; - const parsed = parseMultipart(body.data, { boundary }); + const maxFileSize = 1024 * 1024 * 10; // 10MB + const parsed = parseMultipart(body.data, { boundary, maxFileSize }); const parts = Array.from(parsed); return ( From 394fbbd55d9874c48c759a14a4e3f780fd11f81d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 28 Dec 2025 13:25:24 -0800 Subject: [PATCH 15/17] Refactor content viewer components and use for multpart and request body (#333) --- src-tauri/yaak-http/src/types.rs | 10 +- src-web/components/HttpResponsePane.tsx | 34 +++++- src-web/components/RequestBodyViewer.tsx | 68 +++++++++-- src-web/components/RouteError.tsx | 2 +- src-web/components/core/FormattedError.tsx | 4 +- src-web/components/core/Icon.tsx | 4 +- .../responseViewers/AudioViewer.tsx | 21 +++- .../components/responseViewers/CsvViewer.tsx | 9 +- .../responseViewers/HTMLOrTextViewer.tsx | 47 +++++++- .../components/responseViewers/JsonViewer.tsx | 12 +- .../responseViewers/MultipartViewer.tsx | 106 +++++++++++++----- .../components/responseViewers/PdfViewer.tsx | 23 +++- .../components/responseViewers/SvgViewer.tsx | 18 +-- .../components/responseViewers/TextViewer.tsx | 42 +++---- .../responseViewers/VideoViewer.tsx | 21 +++- .../responseViewers/WebPageViewer.tsx | 20 ++-- 16 files changed, 325 insertions(+), 116 deletions(-) diff --git a/src-tauri/yaak-http/src/types.rs b/src-tauri/yaak-http/src/types.rs index cd3432f4..135ce8bb 100644 --- a/src-tauri/yaak-http/src/types.rs +++ b/src-tauri/yaak-http/src/types.rs @@ -326,8 +326,14 @@ async fn build_multipart_body( if file_path.is_empty() { // Text field - let header = - format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}", name, value); + let header = if !content_type.is_empty() { + format!( + "Content-Disposition: form-data; name=\"{}\"\r\nContent-Type: {}\r\n\r\n{}", + name, content_type, value + ) + } else { + format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}", name, value) + }; let header_bytes = header.into_bytes(); total_size += header_bytes.len(); readers.push(ReaderType::Bytes(header_bytes)); diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index 6465bc24..02c575c8 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -6,6 +6,7 @@ import { useLocalStorage } from 'react-use'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; +import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText'; import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { getMimeTypeFromContentType } from '../lib/contentType'; import { getContentTypeFromHeaders } from '../lib/model_util'; @@ -216,19 +217,19 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( ) : mimeType?.match(/^image\/svg/) ? ( - + ) : mimeType?.match(/^image/i) ? ( ) : mimeType?.match(/^audio/i) ? ( ) : mimeType?.match(/^video/i) ? ( - ) : mimeType?.match(/^multipart/i) ? ( - + ) : mimeType?.match(/^multipart/i) && viewMode === 'pretty' ? ( + ) : mimeType?.match(/pdf/i) ? ( ) : mimeType?.match(/csv|tab-separated/i) ? ( - + ) : ( ; } + +function HttpSvgViewer({ response }: { response: HttpResponse }) { + const body = useResponseBodyText({ response, filter: null }); + + if (!body.data) return null; + + return ; +} + +function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) { + const body = useResponseBodyText({ response, filter: null }); + + return ; +} + +function HttpMultipartViewer({ response }: { response: HttpResponse }) { + const body = useResponseBodyBytes({ response }); + + if (body.data == null) return null; + + const contentTypeHeader = getContentTypeFromHeaders(response.headers); + const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown'; + + return ; +} diff --git a/src-web/components/RequestBodyViewer.tsx b/src-web/components/RequestBodyViewer.tsx index 215c5d25..7e0ccd51 100644 --- a/src-web/components/RequestBodyViewer.tsx +++ b/src-web/components/RequestBodyViewer.tsx @@ -1,9 +1,21 @@ import type { HttpResponse } from '@yaakapp-internal/models'; +import { lazy, Suspense } from 'react'; import { useHttpRequestBody } from '../hooks/useHttpRequestBody'; -import { languageFromContentType } from '../lib/contentType'; +import { getMimeTypeFromContentType, languageFromContentType } from '../lib/contentType'; import { EmptyStateText } from './EmptyStateText'; -import { Editor } from './core/Editor/LazyEditor'; import { LoadingIcon } from './core/LoadingIcon'; +import { AudioViewer } from './responseViewers/AudioViewer'; +import { CsvViewer } from './responseViewers/CsvViewer'; +import { ImageViewer } from './responseViewers/ImageViewer'; +import { MultipartViewer } from './responseViewers/MultipartViewer'; +import { SvgViewer } from './responseViewers/SvgViewer'; +import { TextViewer } from './responseViewers/TextViewer'; +import { VideoViewer } from './responseViewers/VideoViewer'; +import { WebPageViewer } from './responseViewers/WebPageViewer'; + +const PdfViewer = lazy(() => + import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })), +); interface Props { response: HttpResponse; @@ -32,21 +44,59 @@ function RequestBodyViewerInner({ response }: Props) { return No request body; } - const { bodyText } = data; + const { bodyText, body } = data; // Try to detect language from content-type header that was sent const contentTypeHeader = response.requestHeaders.find( (h) => h.name.toLowerCase() === 'content-type', ); const contentType = contentTypeHeader?.value ?? null; + const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null; const language = languageFromContentType(contentType, bodyText); + // Route to appropriate viewer based on content type + if (mimeType?.match(/^multipart/i)) { + const boundary = contentType?.split('boundary=')[1] ?? 'unknown'; + // Create a copy because parseMultipart may detach the buffer + const bodyCopy = new Uint8Array(body); + return ( + + ); + } + + if (mimeType?.match(/^image\/svg/i)) { + return ; + } + + if (mimeType?.match(/^image/i)) { + return ; + } + + if (mimeType?.match(/^audio/i)) { + return ; + } + + if (mimeType?.match(/^video/i)) { + return ; + } + + if (mimeType?.match(/csv|tab-separated/i)) { + return ; + } + + if (mimeType?.match(/^text\/html/i)) { + return ; + } + + if (mimeType?.match(/pdf/i)) { + return ( + }> + + + ); + } + return ( - + ); } diff --git a/src-web/components/RouteError.tsx b/src-web/components/RouteError.tsx index fdce24e3..d6ec5cdc 100644 --- a/src-web/components/RouteError.tsx +++ b/src-web/components/RouteError.tsx @@ -20,7 +20,7 @@ export default function RouteError({ error }: { error: unknown }) { {stack && (
{stack}
diff --git a/src-web/components/core/FormattedError.tsx b/src-web/components/core/FormattedError.tsx index 0d95cade..b7889467 100644 --- a/src-web/components/core/FormattedError.tsx +++ b/src-web/components/core/FormattedError.tsx @@ -3,12 +3,14 @@ import type { ReactNode } from 'react'; interface Props { children: ReactNode; + className?: string; } -export function FormattedError({ children }: Props) { +export function FormattedError({ children, className }: Props) { return (
();
+
+  useEffect(() => {
+    if (bodyPath) {
+      setSrc(convertFileSrc(bodyPath));
+    } else if (data) {
+      const blob = new Blob([data], { type: 'audio/mpeg' });
+      const url = URL.createObjectURL(blob);
+      setSrc(url);
+      return () => URL.revokeObjectURL(url);
+    } else {
+      setSrc(undefined);
+    }
+  }, [bodyPath, data]);
 
   // biome-ignore lint/a11y/useMediaCaption: none
   return