From da6baf72f5f771fb40df55934ec3bbaefe157fc8 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 9 Oct 2024 16:27:37 -0700 Subject: [PATCH] Response Streaming (#124) --- plugin-runtime-types/src/bindings/models.ts | 4 +- .../20241003134208_response-state.sql | 5 + src-tauri/src/http_request.rs | 272 +++++++++++------- src-tauri/src/lib.rs | 32 ++- src-tauri/yaak_models/bindings/models.ts | 8 +- src-tauri/yaak_models/src/models.rs | 38 +++ src-tauri/yaak_models/src/queries.rs | 39 ++- .../yaak_plugin_runtime/bindings/models.ts | 4 +- src-web/components/ResponsePane.tsx | 67 +++-- src-web/components/core/StatusTag.tsx | 26 +- .../responseViewers/AudioViewer.tsx | 11 +- .../responseViewers/BinaryViewer.tsx | 12 + .../responseViewers/HTMLOrTextViewer.tsx | 32 ++- .../responseViewers/ImageViewer.tsx | 39 +-- .../components/responseViewers/PdfViewer.tsx | 13 +- .../components/responseViewers/TextViewer.tsx | 73 ++--- .../responseViewers/VideoViewer.tsx | 11 +- src-web/hooks/useResponseBodyText.ts | 3 +- src-web/lib/contentType.ts | 30 +- src-web/lib/model_util.ts | 7 +- 20 files changed, 425 insertions(+), 301 deletions(-) create mode 100644 src-tauri/migrations/20241003134208_response-state.sql diff --git a/plugin-runtime-types/src/bindings/models.ts b/plugin-runtime-types/src/bindings/models.ts index eb182225..831608d5 100644 --- a/plugin-runtime-types/src/bindings/models.ts +++ b/plugin-runtime-types/src/bindings/models.ts @@ -14,10 +14,12 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string 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, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, 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, 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 HttpResponseHeader = { name: string, value: string, }; +export type HttpResponseState = "initialized" | "connected" | "closed"; + export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, variables: Array, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; diff --git a/src-tauri/migrations/20241003134208_response-state.sql b/src-tauri/migrations/20241003134208_response-state.sql new file mode 100644 index 00000000..7f26c953 --- /dev/null +++ b/src-tauri/migrations/20241003134208_response-state.sql @@ -0,0 +1,5 @@ +ALTER TABLE http_responses + ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL; + +ALTER TABLE grpc_connections + ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL; diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 5b103109..85002c0d 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -1,7 +1,4 @@ use std::collections::BTreeMap; -use std::fs; -use std::fs::{create_dir_all, File}; -use std::io::Write; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -14,17 +11,21 @@ use base64::prelude::BASE64_STANDARD; use base64::Engine; use http::header::{ACCEPT, USER_AGENT}; use http::{HeaderMap, HeaderName, HeaderValue}; -use log::{error, warn}; +use log::{debug, error, warn}; use mime_guess::Mime; use reqwest::redirect::Policy; -use reqwest::Method; use reqwest::{multipart, Url}; +use reqwest::{Method, Response}; use serde_json::Value; use tauri::{Manager, Runtime, WebviewWindow}; +use tokio::fs; +use tokio::fs::{create_dir_all, File}; +use tokio::io::AsyncWriteExt; use tokio::sync::oneshot; use tokio::sync::watch::Receiver; use yaak_models::models::{ Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, + HttpResponseState, }; use yaak_models::queries::{get_workspace, update_response_if_id, upsert_cookie_jar}; use yaak_plugin_runtime::events::{RenderPurpose, WindowContext}; @@ -35,7 +36,7 @@ pub async fn send_http_request( response: &HttpResponse, environment: Option, cookie_jar: Option, - cancel_rx: &mut Receiver, + cancelled_rx: &mut Receiver, ) -> Result { let workspace = get_workspace(window, &request.workspace_id) .await @@ -45,7 +46,7 @@ pub async fn send_http_request( &WindowContext::from_window(window), RenderPurpose::Send, ); - + let rendered_request = render_http_request(&request, &workspace, environment.as_ref(), &cb).await; @@ -114,24 +115,24 @@ pub async fn send_http_request( let uri = match http::Uri::from_str(url_string.as_str()) { Ok(u) => u, Err(e) => { - return response_err( + return Ok(response_err( response, format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), window, ) - .await; + .await); } }; // Yes, we're parsing both URI and URL because they could return different errors let url = match Url::from_str(uri.to_string().as_str()) { Ok(u) => u, Err(e) => { - return response_err( + return Ok(response_err( response, format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), window, ) - .await; + .await); } }; @@ -269,12 +270,12 @@ pub async fn send_http_request( .as_str() .unwrap_or_default(); - match fs::read(file_path).map_err(|e| e.to_string()) { + match fs::read(file_path).await.map_err(|e| e.to_string()) { Ok(f) => { request_builder = request_builder.body(f); } Err(e) => { - return response_err(response, e, window).await; + return Ok(response_err(response, e, window).await); } } } else if body_type == "multipart/form-data" && request_body.contains_key("form") { @@ -297,10 +298,12 @@ pub async fn send_http_request( let mut part = if file_path.is_empty() { multipart::Part::text(value.clone()) } else { - match fs::read(file_path.clone()) { + match fs::read(file_path.clone()).await { Ok(f) => multipart::Part::bytes(f), Err(e) => { - return response_err(response, e.to_string(), window).await; + return Ok( + response_err(response, e.to_string(), window).await + ); } } }; @@ -348,118 +351,167 @@ pub async fn send_http_request( let sendable_req = match request_builder.build() { Ok(r) => r, Err(e) => { - return response_err(response, e.to_string(), window).await; + return Ok(response_err(response, e.to_string(), window).await); } }; - let start = std::time::Instant::now(); + let (resp_tx, resp_rx) = oneshot::channel::>(); + let (done_tx, done_rx) = oneshot::channel::(); - let (resp_tx, resp_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} - _ = cancel_rx.changed() => { - return response_err(response, "Request was cancelled".to_string(), window).await; + Ok(r) = resp_rx => r, + _ = cancelled_rx.changed() => { + debug!("Request cancelled"); + return Ok(response_err(response, "Request was cancelled".to_string(), window).await); } }; - match raw_response { - Ok(v) => { - let mut response = response.clone(); - response.elapsed_headers = start.elapsed().as_millis() as i32; - let response_headers = v.headers().clone(); - response.status = v.status().as_u16() as i32; - response.status_reason = v.status().canonical_reason().map(|s| s.to_string()); - response.headers = response_headers - .iter() - .map(|(k, v)| HttpResponseHeader { - name: k.as_str().to_string(), - value: v.to_str().unwrap_or_default().to_string(), - }) - .collect(); - response.url = v.url().to_string(); - response.remote_addr = v.remote_addr().map(|a| a.to_string()); - response.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, + { + let window = window.clone(); + let response = response.clone(); + let cancelled_rx = cancelled_rx.clone(); + tokio::spawn(async move { + let result = match raw_response { + Ok(mut v) => { + let mut response = response.clone(); + let response_headers = v.headers().clone(); + response.elapsed_headers = start.elapsed().as_millis() as i32; + response.status = v.status().as_u16() as i32; + response.status_reason = v.status().canonical_reason().map(|s| s.to_string()); + response.headers = response_headers + .iter() + .map(|(k, v)| HttpResponseHeader { + name: k.as_str().to_string(), + value: v.to_str().unwrap_or_default().to_string(), + }) + .collect(); + response.url = v.url().to_string(); + response.remote_addr = v.remote_addr().map(|a| a.to_string()); + response.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, + }; + let dir = window.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(response.id.clone()) + } else { + base_dir.join(uuid::Uuid::new_v4().to_string()) + }; + + response.body_path = Some( + body_path + .to_str() + .expect("Failed to get body path") + .to_string(), + ); + + let content_length = v.content_length(); + response.state = HttpResponseState::Connected; + response = update_response_if_id(&window, &response) + .await + .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)) => { + f.write_all(&bytes).await.expect("Failed to write to file"); + f.flush().await.expect("Failed to flush file"); + written_bytes += bytes.len(); + response.elapsed = start.elapsed().as_millis() as i32; + response.content_length = Some(written_bytes as i32); + response = update_response_if_id(&window, &response) + .await + .expect("Failed to update response"); + } + Ok(None) => { + break; + } + Err(e) => { + response = response_err(&response, e.to_string(), &window).await; + break; + } + } + } + + // Set final content length + response.content_length = match content_length { + Some(l) => Some(l as i32), + None => Some(written_bytes as i32), + }; + response.state = HttpResponseState::Closed; + response = update_response_if_id(&window, &response) + .await + .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) = upsert_cookie_jar(&window, &cookie_jar).await { + error!("Failed to update cookie jar: {}", e); + }; + } + response + } + Err(e) => response_err(&response, e.to_string(), &window).await, }; - let content_length = v.content_length(); - let body_bytes = v.bytes().await.expect("Failed to get body").to_vec(); - response.elapsed = start.elapsed().as_millis() as i32; + done_tx.send(result.clone()).unwrap(); + }); + }; - // Use content length if available, otherwise use body length - response.content_length = match content_length { - Some(l) => Some(l as i32), - None => Some(body_bytes.len() as i32), - }; - - { - // Write body to FS - let dir = window.app_handle().path().app_data_dir().unwrap(); - let base_dir = dir.join("responses"); - create_dir_all(base_dir.clone()).expect("Failed to create responses dir"); - let body_path = match response.id.is_empty() { - false => base_dir.join(response.id.clone()), - true => base_dir.join(uuid::Uuid::new_v4().to_string()), - }; - let mut f = File::options() - .create(true) - .truncate(true) - .write(true) - .open(&body_path) - .expect("Failed to open file"); - f.write_all(body_bytes.as_slice()) - .expect("Failed to write to file"); - response.body_path = Some( - body_path - .to_str() - .expect("Failed to get body path") - .to_string(), - ); - } - - response = update_response_if_id(window, &response) - .await - .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) = upsert_cookie_jar(window, &cookie_jar).await { - error!("Failed to update cookie jar: {}", e); - }; - } - - Ok(response) + Ok(tokio::select! { + Ok(r) = done_rx => r, + _ = cancelled_rx.changed() => { + response_err(&response, "Request was cancelled".to_string(), &window).await } - Err(e) => response_err(response, e.to_string(), window).await, - } + }) } fn ensure_proto(url_str: &str) -> String { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d48e3225..f4acb9fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,8 +43,9 @@ use crate::template_callback::PluginTemplateCallback; use crate::updates::{UpdateMode, YaakUpdater}; use crate::window_menu::app_menu; use yaak_models::models::{ - CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType, - GrpcRequest, HttpRequest, HttpResponse, KeyValue, ModelType, Plugin, Settings, Workspace, + CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState, + GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue, + ModelType, Plugin, Settings, Workspace, }; use yaak_models::queries::{ cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response, @@ -280,6 +281,7 @@ async fn cmd_grpc_go( request_id: req.id, status: -1, elapsed: 0, + state: GrpcConnectionState::Initialized, url: req.url.clone(), ..Default::default() }, @@ -335,6 +337,7 @@ async fn cmd_grpc_go( &GrpcConnection { elapsed: start.elapsed().as_millis() as i32, error: Some(err.clone()), + state: GrpcConnectionState::Initialized, ..conn.clone() }, ) @@ -689,6 +692,7 @@ async fn cmd_grpc_go( &GrpcConnection{ elapsed: start.elapsed().as_millis() as i32, status: closed_status, + state: GrpcConnectionState::Closed, ..get_grpc_connection(&w, &conn_id).await.unwrap().clone() }, ).await.unwrap(); @@ -708,6 +712,7 @@ async fn cmd_grpc_go( &GrpcConnection { elapsed: start.elapsed().as_millis() as i32, status: Code::Cancelled as i32, + state: GrpcConnectionState::Closed, ..get_grpc_connection(&w, &conn_id).await.unwrap().clone() }, ) @@ -752,7 +757,9 @@ async fn cmd_send_ephemeral_request( window.listen_any( format!("cancel_http_response_{}", response.id), move |_event| { - let _ = cancel_tx.send(true); + if let Err(e) = cancel_tx.send(true) { + warn!("Failed to send cancel event for ephemeral request {e:?}"); + } }, ); @@ -1090,7 +1097,9 @@ async fn cmd_send_http_request( window.listen_any( format!("cancel_http_response_{}", response.id), move |_event| { - let _ = cancel_tx.send(true); + if let Err(e) = cancel_tx.send(true) { + warn!("Failed to send cancel event for request {e:?}"); + } }, ); @@ -1129,15 +1138,15 @@ async fn response_err( response: &HttpResponse, error: String, w: &WebviewWindow, -) -> Result { +) -> HttpResponse { warn!("Failed to send request: {}", error); let mut response = response.clone(); - response.elapsed = -1; + response.state = HttpResponseState::Closed; response.error = Some(error.clone()); response = update_response_if_id(w, &response) .await .expect("Failed to update response"); - Ok(response) + response } #[tauri::command] @@ -2182,13 +2191,16 @@ async fn call_frontend( let (tx, mut rx) = tokio::sync::watch::channel(PromptTextResponse::default()); let event_id = window.clone().listen(reply_id, move |ev| { - println!("GOT REPLY {ev:?}"); let resp: PromptTextResponse = serde_json::from_str(ev.payload()).unwrap(); - _ = tx.send(resp); + if let Err(e) = tx.send(resp) { + warn!("Failed to prompt for text {e:?}"); + } }); // When reply shows up, unlisten to events and return - _ = rx.changed().await; + if let Err(e) = rx.changed().await { + warn!("Failed to check channel changed {e:?}"); + } window.unlisten(event_id); let foo = rx.borrow(); diff --git a/src-tauri/yaak_models/bindings/models.ts b/src-tauri/yaak_models/bindings/models.ts index 18ef5091..0f25155c 100644 --- a/src-tauri/yaak_models/bindings/models.ts +++ b/src-tauri/yaak_models/bindings/models.ts @@ -16,7 +16,9 @@ export type EnvironmentVariable = { enabled?: boolean, name: string, value: stri export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, sortPriority: number, }; -export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, trailers: { [key in string]?: string }, url: string, }; +export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, }; + +export type GrpcConnectionState = "initialized" | "connected" | "closed"; export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, }; @@ -30,10 +32,12 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string 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, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, 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, 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 HttpResponseHeader = { name: string, value: string, }; +export type HttpResponseState = "initialized" | "connected" | "closed"; + export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; export type KeyValue = { model: "key_value", createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; diff --git a/src-tauri/yaak_models/src/models.rs b/src-tauri/yaak_models/src/models.rs index 77d686c0..09f7fae2 100644 --- a/src-tauri/yaak_models/src/models.rs +++ b/src-tauri/yaak_models/src/models.rs @@ -430,6 +430,21 @@ pub struct HttpResponseHeader { pub value: String, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( rename_all = "snake_case")] +#[ts(export, export_to = "models.ts")] +pub enum HttpResponseState { + Initialized, + Connected, + Closed, +} + +impl Default for HttpResponseState { + fn default() -> Self { + Self::Initialized + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "models.ts")] @@ -451,6 +466,7 @@ pub struct HttpResponse { pub remote_addr: Option, pub status: i32, pub status_reason: Option, + pub state: HttpResponseState, pub url: String, pub version: Option, } @@ -475,6 +491,7 @@ pub enum HttpResponseIden { RemoteAddr, Status, StatusReason, + State, Url, Version, } @@ -484,6 +501,7 @@ impl<'s> TryFrom<&Row<'s>> for HttpResponse { fn try_from(r: &Row<'s>) -> Result { let headers: String = r.get("headers")?; + let state: String = r.get("state")?; Ok(HttpResponse { id: r.get("id")?, model: r.get("model")?, @@ -500,6 +518,7 @@ impl<'s> TryFrom<&Row<'s>> for HttpResponse { remote_addr: r.get("remote_addr")?, status: r.get("status")?, status_reason: r.get("status_reason")?, + 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(), }) @@ -598,6 +617,21 @@ impl<'s> TryFrom<&Row<'s>> for GrpcRequest { } } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( rename_all = "snake_case")] +#[ts(export, export_to = "models.ts")] +pub enum GrpcConnectionState { + Initialized, + Connected, + Closed, +} + +impl Default for GrpcConnectionState{ + fn default() -> Self { + Self::Initialized + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "models.ts")] @@ -615,6 +649,7 @@ pub struct GrpcConnection { pub method: String, pub service: String, pub status: i32, + pub state: GrpcConnectionState, pub trailers: BTreeMap, pub url: String, } @@ -634,6 +669,7 @@ pub enum GrpcConnectionIden { Error, Method, Service, + State, Status, Trailers, Url, @@ -644,6 +680,7 @@ impl<'s> TryFrom<&Row<'s>> for GrpcConnection { fn try_from(r: &Row<'s>) -> Result { let trailers: String = r.get("trailers")?; + let state: String = r.get("state")?; Ok(GrpcConnection { id: r.get("id")?, model: r.get("model")?, @@ -654,6 +691,7 @@ impl<'s> TryFrom<&Row<'s>> for GrpcConnection { service: r.get("service")?, method: r.get("method")?, elapsed: r.get("elapsed")?, + state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), status: r.get("status")?, url: r.get("url")?, error: r.get("error")?, diff --git a/src-tauri/yaak_models/src/queries.rs b/src-tauri/yaak_models/src/queries.rs index c2b2c50e..63d438e5 100644 --- a/src-tauri/yaak_models/src/queries.rs +++ b/src-tauri/yaak_models/src/queries.rs @@ -3,9 +3,10 @@ use std::fs; use crate::error::Result; use crate::models::{ CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden, GrpcConnection, - GrpcConnectionIden, GrpcEvent, GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, - HttpRequestIden, HttpResponse, HttpResponseHeader, HttpResponseIden, KeyValue, KeyValueIden, - ModelType, Plugin, PluginIden, Settings, SettingsIden, Workspace, WorkspaceIden, + GrpcConnectionIden, GrpcConnectionState, GrpcEvent, GrpcEventIden, GrpcRequest, + GrpcRequestIden, HttpRequest, HttpRequestIden, HttpResponse, HttpResponseHeader, + HttpResponseIden, HttpResponseState, KeyValue, KeyValueIden, ModelType, Plugin, PluginIden, + Settings, SettingsIden, Workspace, WorkspaceIden, }; use crate::plugin::SqliteConnection; use log::{debug, error}; @@ -433,7 +434,10 @@ pub async fn upsert_grpc_connection( ) -> Result { let connections = list_http_responses_for_request(window, connection.request_id.as_str(), None).await?; - for c in connections.iter().skip(MAX_GRPC_CONNECTIONS_PER_REQUEST - 1) { + for c in connections + .iter() + .skip(MAX_GRPC_CONNECTIONS_PER_REQUEST - 1) + { debug!("Deleting old grpc connection {}", c.id); delete_grpc_connection(window, c.id.as_str()).await?; } @@ -455,6 +459,7 @@ pub async fn upsert_grpc_connection( GrpcConnectionIden::Service, GrpcConnectionIden::Method, GrpcConnectionIden::Elapsed, + GrpcConnectionIden::State, GrpcConnectionIden::Status, GrpcConnectionIden::Error, GrpcConnectionIden::Trailers, @@ -469,6 +474,7 @@ pub async fn upsert_grpc_connection( connection.service.as_str().into(), connection.method.as_str().into(), connection.elapsed.into(), + serde_json::to_value(&connection.state)?.as_str().into(), connection.status.into(), connection.error.as_ref().map(|s| s.as_str()).into(), serde_json::to_string(&connection.trailers)?.into(), @@ -1233,6 +1239,7 @@ pub async fn create_default_http_response( 0, 0, "", + HttpResponseState::Initialized, 0, None, None, @@ -1251,6 +1258,7 @@ pub async fn create_http_response( elapsed: i64, elapsed_headers: i64, url: &str, + state: HttpResponseState, status: i64, status_reason: Option<&str>, content_length: Option, @@ -1281,6 +1289,7 @@ pub async fn create_http_response( HttpResponseIden::Elapsed, HttpResponseIden::ElapsedHeaders, HttpResponseIden::Url, + HttpResponseIden::State, HttpResponseIden::Status, HttpResponseIden::StatusReason, HttpResponseIden::ContentLength, @@ -1298,6 +1307,10 @@ pub async fn create_http_response( elapsed.into(), elapsed_headers.into(), url.into(), + serde_json::to_value(state)? + .as_str() + .unwrap_or_default() + .into(), status.into(), status_reason.into(), content_length.into(), @@ -1318,10 +1331,11 @@ pub async fn cancel_pending_grpc_connections(app: &AppHandle) -> Result<()> { let dbm = &*app.app_handle().state::(); let db = dbm.0.lock().await.get().unwrap(); + let closed = serde_json::to_value(&GrpcConnectionState::Closed)?; let (sql, params) = Query::update() .table(GrpcConnectionIden::Table) - .value(GrpcConnectionIden::Elapsed, -1) - .cond_where(Expr::col(GrpcConnectionIden::Elapsed).eq(0)) + .values([(GrpcConnectionIden::State, closed.as_str().into())]) + .cond_where(Expr::col(GrpcConnectionIden::State).ne(closed.as_str())) .build_rusqlite(SqliteQueryBuilder); db.execute(sql.as_str(), &*params.as_params())?; @@ -1332,13 +1346,14 @@ pub async fn cancel_pending_responses(app: &AppHandle) -> Result<()> { let dbm = &*app.app_handle().state::(); let db = dbm.0.lock().await.get().unwrap(); + let closed = serde_json::to_value(&GrpcConnectionState::Closed)?; let (sql, params) = Query::update() .table(HttpResponseIden::Table) .values([ - (HttpResponseIden::Elapsed, (-1i32).into()), + (HttpResponseIden::State, closed.as_str().into()), (HttpResponseIden::StatusReason, "Cancelled".into()), ]) - .cond_where(Expr::col(HttpResponseIden::Elapsed).eq(0)) + .cond_where(Expr::col(HttpResponseIden::State).ne(closed.as_str())) .build_rusqlite(SqliteQueryBuilder); db.execute(sql.as_str(), &*params.as_params())?; @@ -1352,11 +1367,11 @@ pub async fn update_response_if_id( if response.id.is_empty() { Ok(response.clone()) } else { - update_response(window, response).await + update_http_response(window, response).await } } -pub async fn update_response( +pub async fn update_http_response( window: &WebviewWindow, response: &HttpResponse, ) -> Result { @@ -1397,6 +1412,10 @@ pub async fn update_response( HttpResponseIden::Version, response.version.as_ref().map(|s| s.as_str()).into(), ), + ( + HttpResponseIden::State, + serde_json::to_value(&response.state)?.as_str().into(), + ), ( HttpResponseIden::RemoteAddr, response.remote_addr.as_ref().map(|s| s.as_str()).into(), diff --git a/src-tauri/yaak_plugin_runtime/bindings/models.ts b/src-tauri/yaak_plugin_runtime/bindings/models.ts index eb182225..831608d5 100644 --- a/src-tauri/yaak_plugin_runtime/bindings/models.ts +++ b/src-tauri/yaak_plugin_runtime/bindings/models.ts @@ -14,10 +14,12 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string 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, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, status: number, statusReason: string | null, 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, 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 HttpResponseHeader = { name: string, value: string, }; +export type HttpResponseState = "initialized" | "connected" | "closed"; + export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, variables: Array, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index d4de71ec..500490d8 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -1,7 +1,7 @@ -import type { HttpRequest } from '@yaakapp-internal/models'; +import type { HttpRequest, HttpResponse } from '@yaakapp-internal/models'; import classNames from 'classnames'; -import type { CSSProperties } from 'react'; -import { memo, useCallback, useMemo } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { useLocalStorage } from 'react-use'; import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; @@ -88,6 +88,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ [activeResponse?.headers, contentType, setViewMode, viewMode], ); + const isLoading = isResponseLoading(activeResponse); + return (
- ) : isResponseLoading(activeResponse) ? ( -
- -
) : (
+ {isLoading && } - {activeResponse.elapsed > 0 && ( - <> - - - - )} - {!!activeResponse.contentLength && ( - <> - - - - )} + + + +
Empty Body
) : contentType?.startsWith('image') ? ( - + ) : contentType?.startsWith('audio') ? ( - + ) : contentType?.startsWith('video') ? ( - + ) : contentType?.match(/pdf/) ? ( - + ) : contentType?.match(/csv|tab-separated/) ? ( ) : ( @@ -204,3 +196,26 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
); }); + +function EnsureCompleteResponse({ + response, + render, +}: { + response: HttpResponse; + render: (v: { bodyPath: string }) => ReactNode; +}) { + if (response.bodyPath === null) { + return
Empty response body
; + } + + // Wait until the response has been fully-downloaded + if (response.state !== 'closed') { + return ( + + + + ); + } + + return render({ bodyPath: response.bodyPath }); +} diff --git a/src-web/components/core/StatusTag.tsx b/src-web/components/core/StatusTag.tsx index 43326ec1..96b1056c 100644 --- a/src-web/components/core/StatusTag.tsx +++ b/src-web/components/core/StatusTag.tsx @@ -1,30 +1,34 @@ -import classNames from 'classnames'; import type { HttpResponse } from '@yaakapp-internal/models'; +import classNames from 'classnames'; interface Props { - response: Pick; + response: HttpResponse; className?: string; showReason?: boolean; } export function StatusTag({ response, className, showReason }: Props) { - const { status } = response; - const label = status < 100 ? 'ERR' : status; + const { status, state } = response; + const label = status < 100 ? 'ERROR' : status; const category = `${status}`[0]; + const isInitializing = state === 'initialized'; + return ( - {label} {showReason && response.statusReason && response.statusReason} + {isInitializing ? 'CONNECTING' : label}{' '} + {showReason && response.statusReason && response.statusReason} ); } diff --git a/src-web/components/responseViewers/AudioViewer.tsx b/src-web/components/responseViewers/AudioViewer.tsx index c2956759..66b01e32 100644 --- a/src-web/components/responseViewers/AudioViewer.tsx +++ b/src-web/components/responseViewers/AudioViewer.tsx @@ -1,17 +1,12 @@ import { convertFileSrc } from '@tauri-apps/api/core'; import React from 'react'; -import type { HttpResponse } from '@yaakapp-internal/models'; interface Props { - response: HttpResponse; + bodyPath: string; } -export function AudioViewer({ response }: Props) { - if (response.bodyPath === null) { - return
Empty response body
; - } - - const src = convertFileSrc(response.bodyPath); +export function AudioViewer({ bodyPath }: Props) { + const src = convertFileSrc(bodyPath); // eslint-disable-next-line jsx-a11y/media-has-caption return ; diff --git a/src-web/components/responseViewers/BinaryViewer.tsx b/src-web/components/responseViewers/BinaryViewer.tsx index 38f04ae7..206863e6 100644 --- a/src-web/components/responseViewers/BinaryViewer.tsx +++ b/src-web/components/responseViewers/BinaryViewer.tsx @@ -3,7 +3,9 @@ import type { HttpResponse } from '@yaakapp-internal/models'; import { getContentTypeHeader } from '../../lib/model_util'; import { Banner } from '../core/Banner'; import { Button } from '../core/Button'; +import { Icon } from '../core/Icon'; import { InlineCode } from '../core/InlineCode'; +import { EmptyStateText } from '../EmptyStateText'; interface Props { response: HttpResponse; @@ -12,6 +14,16 @@ interface Props { export function BinaryViewer({ response }: Props) { const saveResponse = useSaveResponse(response); const contentType = getContentTypeHeader(response.headers) ?? 'unknown'; + + // Wait until the response has been fully-downloaded + if (response.state !== 'closed') { + return ( + + + + ); + } + return (

diff --git a/src-web/components/responseViewers/HTMLOrTextViewer.tsx b/src-web/components/responseViewers/HTMLOrTextViewer.tsx index 388f7ab7..381c1d3f 100644 --- a/src-web/components/responseViewers/HTMLOrTextViewer.tsx +++ b/src-web/components/responseViewers/HTMLOrTextViewer.tsx @@ -1,7 +1,8 @@ import type { HttpResponse } from '@yaakapp-internal/models'; import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders'; import { useResponseBodyText } from '../../hooks/useResponseBodyText'; -import { isJSON, languageFromContentType } from '../../lib/contentType'; +import { useSaveResponse } from '../../hooks/useSaveResponse'; +import { languageFromContentType } from '../../lib/contentType'; import { BinaryViewer } from './BinaryViewer'; import { TextViewer } from './TextViewer'; import { WebPageViewer } from './WebPageViewer'; @@ -13,25 +14,34 @@ interface Props { } export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) { - const rawBody = useResponseBodyText(response); - let language = languageFromContentType(useContentTypeFromHeaders(response.headers)); + const rawTextBody = useResponseBodyText(response); + const language = languageFromContentType( + useContentTypeFromHeaders(response.headers), + rawTextBody.data ?? '', + ); + const saveResponse = useSaveResponse(response); - // A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so - if (language === 'html' && isJSON(rawBody.data ?? '')) { - language = 'json'; - } - - if (rawBody.isLoading) { + if (rawTextBody.isLoading) { return null; } - if (rawBody.data == null) { + // Wasn't able to decode as text, so it must be binary + if (rawTextBody.data == null) { return ; } if (language === 'html' && pretty) { return ; } else { - return ; + return ( + + ); } } diff --git a/src-web/components/responseViewers/ImageViewer.tsx b/src-web/components/responseViewers/ImageViewer.tsx index 904e33cc..8ae41dc1 100644 --- a/src-web/components/responseViewers/ImageViewer.tsx +++ b/src-web/components/responseViewers/ImageViewer.tsx @@ -1,40 +1,11 @@ import { convertFileSrc } from '@tauri-apps/api/core'; -import classNames from 'classnames'; -import { useState } from 'react'; -import type { HttpResponse } from '@yaakapp-internal/models'; +import React from 'react'; interface Props { - response: HttpResponse; - className?: string; + bodyPath: string; } -export function ImageViewer({ response, className }: Props) { - const bytes = response.contentLength ?? 0; - const [show, setShow] = useState(bytes < 3 * 1000 * 1000); - - if (response.bodyPath === null) { - return

Empty response body
; - } - - const src = convertFileSrc(response.bodyPath); - if (!show) { - return ( - <> -
- Response body is too large to preview.{' '} - -
- - ); - } - - return ( - Response preview - ); +export function ImageViewer({ bodyPath }: Props) { + const src = convertFileSrc(bodyPath); + return Response preview; } diff --git a/src-web/components/responseViewers/PdfViewer.tsx b/src-web/components/responseViewers/PdfViewer.tsx index 052dba8f..ee3ca0ba 100644 --- a/src-web/components/responseViewers/PdfViewer.tsx +++ b/src-web/components/responseViewers/PdfViewer.tsx @@ -2,15 +2,14 @@ import useResizeObserver from '@react-hook/resize-observer'; import 'react-pdf/dist/Page/TextLayer.css'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import { convertFileSrc } from '@tauri-apps/api/core'; +import './PdfViewer.css'; import type { PDFDocumentProxy } from 'pdfjs-dist'; import React, { useRef, useState } from 'react'; import { Document, Page } from 'react-pdf'; import { useDebouncedState } from '../../hooks/useDebouncedState'; -import type { HttpResponse } from '@yaakapp-internal/models'; -import './PdfViewer.css'; interface Props { - response: HttpResponse; + bodyPath: string; } const options = { @@ -18,7 +17,7 @@ const options = { standardFontDataUrl: '/standard_fonts/', }; -export function PdfViewer({ response }: Props) { +export function PdfViewer({ bodyPath }: Props) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useDebouncedState(0, 100); const [numPages, setNumPages] = useState(); @@ -31,11 +30,7 @@ export function PdfViewer({ response }: Props) { setNumPages(nextNumPages); }; - if (response.bodyPath === null) { - return
Empty response body
; - } - - const src = convertFileSrc(response.bodyPath); + const src = convertFileSrc(bodyPath); return (
void; } const useFilterText = createGlobalState>({}); -export function TextViewer({ response, pretty, className }: Props) { +export function TextViewer({ + language, + text, + responseId, + pretty, + className, + onSaveResponse, +}: Props) { const [filterTextMap, setFilterTextMap] = useFilterText(); const [showLargeResponse, toggleShowLargeResponse] = useToggle(); - const filterText = filterTextMap[response.id] ?? null; + const filterText = filterTextMap[responseId] ?? null; + const copy = useCopy(); const debouncedFilterText = useDebouncedValue(filterText, 200); const setFilterText = useCallback( (v: string | null) => { - setFilterTextMap((m) => ({ ...m, [response.id]: v })); + setFilterTextMap((m) => ({ ...m, [responseId]: v })); }, - [setFilterTextMap, response], + [setFilterTextMap, responseId], ); - const rawBody = useResponseBodyText(response); - const saveResponse = useSaveResponse(response); - let language = languageFromContentType(useContentTypeFromHeaders(response.headers)); - - // A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so - if (language === 'html' && isJSON(rawBody.data ?? '')) { - language = 'json'; - } - const isSearching = filterText != null; - - const filteredResponse = useFilterResponse({ - filter: debouncedFilterText ?? '', - responseId: response.id, - }); + const filteredResponse = useFilterResponse({ filter: debouncedFilterText ?? '', responseId }); const toggleSearch = useCallback(() => { if (isSearching) { @@ -81,7 +75,7 @@ export function TextViewer({ response, pretty, className }: Props) { nodes.push(
; - } - - if (!showLargeResponse && (response.contentLength ?? 0) > LARGE_RESPONSE_BYTES) { + if (!showLargeResponse && text.length > LARGE_RESPONSE_BYTES) { return (

@@ -143,15 +129,10 @@ export function TextViewer({ response, pretty, className }: Props) { - - saveResponse.mutate()} - text={rawBody.data} - /> + copy(text)} text={text} /> ); @@ -159,10 +140,10 @@ export function TextViewer({ response, pretty, className }: Props) { const formattedBody = pretty && language === 'json' - ? tryFormatJson(rawBody.data) + ? tryFormatJson(text) : pretty && (language === 'xml' || language === 'html') - ? tryFormatXml(rawBody.data) - : rawBody.data; + ? tryFormatXml(text) + : text; let body; if (isSearching && filterText?.length > 0) { diff --git a/src-web/components/responseViewers/VideoViewer.tsx b/src-web/components/responseViewers/VideoViewer.tsx index 766d5acc..39edac0e 100644 --- a/src-web/components/responseViewers/VideoViewer.tsx +++ b/src-web/components/responseViewers/VideoViewer.tsx @@ -1,17 +1,12 @@ import { convertFileSrc } from '@tauri-apps/api/core'; import React from 'react'; -import type { HttpResponse } from '@yaakapp-internal/models'; interface Props { - response: HttpResponse; + bodyPath: string; } -export function VideoViewer({ response }: Props) { - if (response.bodyPath === null) { - return

Empty response body
; - } - - const src = convertFileSrc(response.bodyPath); +export function VideoViewer({ bodyPath }: Props) { + const src = convertFileSrc(bodyPath); // eslint-disable-next-line jsx-a11y/media-has-caption return ; diff --git a/src-web/hooks/useResponseBodyText.ts b/src-web/hooks/useResponseBodyText.ts index e631f227..8f7a9c8b 100644 --- a/src-web/hooks/useResponseBodyText.ts +++ b/src-web/hooks/useResponseBodyText.ts @@ -4,7 +4,8 @@ import { getResponseBodyText } from '../lib/responseBody'; export function useResponseBodyText(response: HttpResponse) { return useQuery({ - queryKey: ['response-body-text', response.id, response?.updatedAt], + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: ['response-body-text', response.id, response.updatedAt, response.contentLength], queryFn: () => getResponseBodyText(response), }); } diff --git a/src-web/lib/contentType.ts b/src-web/lib/contentType.ts index 73885d70..32dbbaaa 100644 --- a/src-web/lib/contentType.ts +++ b/src-web/lib/contentType.ts @@ -1,26 +1,34 @@ import type { EditorProps } from '../components/core/Editor'; -export function languageFromContentType(contentType: string | null): EditorProps['language'] { +export function languageFromContentType( + contentType: string | null, + content: string | null = null, +): EditorProps['language'] { const justContentType = contentType?.split(';')[0] ?? contentType ?? ''; if (justContentType.includes('json')) { return 'json'; } else if (justContentType.includes('xml')) { return 'xml'; } else if (justContentType.includes('html')) { - return 'html'; + return detectFromContent(content); } else if (justContentType.includes('javascript')) { return 'javascript'; - } else { - return 'text'; } + + return detectFromContent(content); } export function isJSON(text: string): boolean { - try { - JSON.parse(text); - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (err) { - return false; - } + return text.startsWith('{') || text.startsWith('['); +} + +function detectFromContent(content: string | null): EditorProps['language'] { + if (content == null) return 'text'; + + if (content.startsWith('{') || content.startsWith('[')) { + return 'json'; + } else if (content.startsWith(' | null, +): boolean { + if (response == null) return false; + return response.state !== 'closed'; } export function modelsEq(a: AnyModel, b: AnyModel) {