Response Streaming (#124)

This commit is contained in:
Gregory Schier
2024-10-09 16:27:37 -07:00
committed by GitHub
parent 2ca30bcb31
commit da6baf72f5
20 changed files with 425 additions and 301 deletions

View File

@@ -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 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<HttpResponseHeader>, 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<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; 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<EnvironmentVariable>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, variables: Array<EnvironmentVariable>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -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;

View File

@@ -1,7 +1,4 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs;
use std::fs::{create_dir_all, File};
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
@@ -14,17 +11,21 @@ use base64::prelude::BASE64_STANDARD;
use base64::Engine; use base64::Engine;
use http::header::{ACCEPT, USER_AGENT}; use http::header::{ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue}; use http::{HeaderMap, HeaderName, HeaderValue};
use log::{error, warn}; use log::{debug, error, warn};
use mime_guess::Mime; use mime_guess::Mime;
use reqwest::redirect::Policy; use reqwest::redirect::Policy;
use reqwest::Method;
use reqwest::{multipart, Url}; use reqwest::{multipart, Url};
use reqwest::{Method, Response};
use serde_json::Value; use serde_json::Value;
use tauri::{Manager, Runtime, WebviewWindow}; 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::oneshot;
use tokio::sync::watch::Receiver; use tokio::sync::watch::Receiver;
use yaak_models::models::{ use yaak_models::models::{
Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader,
HttpResponseState,
}; };
use yaak_models::queries::{get_workspace, update_response_if_id, upsert_cookie_jar}; use yaak_models::queries::{get_workspace, update_response_if_id, upsert_cookie_jar};
use yaak_plugin_runtime::events::{RenderPurpose, WindowContext}; use yaak_plugin_runtime::events::{RenderPurpose, WindowContext};
@@ -35,7 +36,7 @@ pub async fn send_http_request<R: Runtime>(
response: &HttpResponse, response: &HttpResponse,
environment: Option<Environment>, environment: Option<Environment>,
cookie_jar: Option<CookieJar>, cookie_jar: Option<CookieJar>,
cancel_rx: &mut Receiver<bool>, cancelled_rx: &mut Receiver<bool>,
) -> Result<HttpResponse, String> { ) -> Result<HttpResponse, String> {
let workspace = get_workspace(window, &request.workspace_id) let workspace = get_workspace(window, &request.workspace_id)
.await .await
@@ -45,7 +46,7 @@ pub async fn send_http_request<R: Runtime>(
&WindowContext::from_window(window), &WindowContext::from_window(window),
RenderPurpose::Send, RenderPurpose::Send,
); );
let rendered_request = let rendered_request =
render_http_request(&request, &workspace, environment.as_ref(), &cb).await; render_http_request(&request, &workspace, environment.as_ref(), &cb).await;
@@ -114,24 +115,24 @@ pub async fn send_http_request<R: Runtime>(
let uri = match http::Uri::from_str(url_string.as_str()) { let uri = match http::Uri::from_str(url_string.as_str()) {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
return response_err( return Ok(response_err(
response, response,
format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()),
window, window,
) )
.await; .await);
} }
}; };
// Yes, we're parsing both URI and URL because they could return different errors // 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()) { let url = match Url::from_str(uri.to_string().as_str()) {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
return response_err( return Ok(response_err(
response, response,
format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()),
window, window,
) )
.await; .await);
} }
}; };
@@ -269,12 +270,12 @@ pub async fn send_http_request<R: Runtime>(
.as_str() .as_str()
.unwrap_or_default(); .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) => { Ok(f) => {
request_builder = request_builder.body(f); request_builder = request_builder.body(f);
} }
Err(e) => { 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") { } else if body_type == "multipart/form-data" && request_body.contains_key("form") {
@@ -297,10 +298,12 @@ pub async fn send_http_request<R: Runtime>(
let mut part = if file_path.is_empty() { let mut part = if file_path.is_empty() {
multipart::Part::text(value.clone()) multipart::Part::text(value.clone())
} else { } else {
match fs::read(file_path.clone()) { match fs::read(file_path.clone()).await {
Ok(f) => multipart::Part::bytes(f), Ok(f) => multipart::Part::bytes(f),
Err(e) => { 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<R: Runtime>(
let sendable_req = match request_builder.build() { let sendable_req = match request_builder.build() {
Ok(r) => r, Ok(r) => r,
Err(e) => { 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::<Result<Response, reqwest::Error>>();
let (done_tx, done_rx) = oneshot::channel::<HttpResponse>();
let (resp_tx, resp_rx) = oneshot::channel(); let start = std::time::Instant::now();
tokio::spawn(async move { tokio::spawn(async move {
let _ = resp_tx.send(client.execute(sendable_req).await); let _ = resp_tx.send(client.execute(sendable_req).await);
}); });
let raw_response = tokio::select! { let raw_response = tokio::select! {
Ok(r) = resp_rx => {r} Ok(r) = resp_rx => r,
_ = cancel_rx.changed() => { _ = cancelled_rx.changed() => {
return response_err(response, "Request was cancelled".to_string(), window).await; debug!("Request cancelled");
return Ok(response_err(response, "Request was cancelled".to_string(), window).await);
} }
}; };
match raw_response { {
Ok(v) => { let window = window.clone();
let mut response = response.clone(); let response = response.clone();
response.elapsed_headers = start.elapsed().as_millis() as i32; let cancelled_rx = cancelled_rx.clone();
let response_headers = v.headers().clone(); tokio::spawn(async move {
response.status = v.status().as_u16() as i32; let result = match raw_response {
response.status_reason = v.status().canonical_reason().map(|s| s.to_string()); Ok(mut v) => {
response.headers = response_headers let mut response = response.clone();
.iter() let response_headers = v.headers().clone();
.map(|(k, v)| HttpResponseHeader { response.elapsed_headers = start.elapsed().as_millis() as i32;
name: k.as_str().to_string(), response.status = v.status().as_u16() as i32;
value: v.to_str().unwrap_or_default().to_string(), response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
}) response.headers = response_headers
.collect(); .iter()
response.url = v.url().to_string(); .map(|(k, v)| HttpResponseHeader {
response.remote_addr = v.remote_addr().map(|a| a.to_string()); name: k.as_str().to_string(),
response.version = match v.version() { value: v.to_str().unwrap_or_default().to_string(),
reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()), })
reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()), .collect();
reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()), response.url = v.url().to_string();
reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()), response.remote_addr = v.remote_addr().map(|a| a.to_string());
reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()), response.version = match v.version() {
_ => None, 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> = 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::<Vec<_>>();
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(); done_tx.send(result.clone()).unwrap();
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec(); });
response.elapsed = start.elapsed().as_millis() as i32; };
// Use content length if available, otherwise use body length Ok(tokio::select! {
response.content_length = match content_length { Ok(r) = done_rx => r,
Some(l) => Some(l as i32), _ = cancelled_rx.changed() => {
None => Some(body_bytes.len() as i32), response_err(&response, "Request was cancelled".to_string(), &window).await
};
{
// 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> = 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::<Vec<_>>();
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)
} }
Err(e) => response_err(response, e.to_string(), window).await, })
}
} }
fn ensure_proto(url_str: &str) -> String { fn ensure_proto(url_str: &str) -> String {

View File

@@ -43,8 +43,9 @@ use crate::template_callback::PluginTemplateCallback;
use crate::updates::{UpdateMode, YaakUpdater}; use crate::updates::{UpdateMode, YaakUpdater};
use crate::window_menu::app_menu; use crate::window_menu::app_menu;
use yaak_models::models::{ use yaak_models::models::{
CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType, CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState,
GrpcRequest, HttpRequest, HttpResponse, KeyValue, ModelType, Plugin, Settings, Workspace, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue,
ModelType, Plugin, Settings, Workspace,
}; };
use yaak_models::queries::{ use yaak_models::queries::{
cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response, cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response,
@@ -280,6 +281,7 @@ async fn cmd_grpc_go<R: Runtime>(
request_id: req.id, request_id: req.id,
status: -1, status: -1,
elapsed: 0, elapsed: 0,
state: GrpcConnectionState::Initialized,
url: req.url.clone(), url: req.url.clone(),
..Default::default() ..Default::default()
}, },
@@ -335,6 +337,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcConnection { &GrpcConnection {
elapsed: start.elapsed().as_millis() as i32, elapsed: start.elapsed().as_millis() as i32,
error: Some(err.clone()), error: Some(err.clone()),
state: GrpcConnectionState::Initialized,
..conn.clone() ..conn.clone()
}, },
) )
@@ -689,6 +692,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcConnection{ &GrpcConnection{
elapsed: start.elapsed().as_millis() as i32, elapsed: start.elapsed().as_millis() as i32,
status: closed_status, status: closed_status,
state: GrpcConnectionState::Closed,
..get_grpc_connection(&w, &conn_id).await.unwrap().clone() ..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
).await.unwrap(); ).await.unwrap();
@@ -708,6 +712,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcConnection { &GrpcConnection {
elapsed: start.elapsed().as_millis() as i32, elapsed: start.elapsed().as_millis() as i32,
status: Code::Cancelled as i32, status: Code::Cancelled as i32,
state: GrpcConnectionState::Closed,
..get_grpc_connection(&w, &conn_id).await.unwrap().clone() ..get_grpc_connection(&w, &conn_id).await.unwrap().clone()
}, },
) )
@@ -752,7 +757,9 @@ async fn cmd_send_ephemeral_request(
window.listen_any( window.listen_any(
format!("cancel_http_response_{}", response.id), format!("cancel_http_response_{}", response.id),
move |_event| { 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( window.listen_any(
format!("cancel_http_response_{}", response.id), format!("cancel_http_response_{}", response.id),
move |_event| { 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<R: Runtime>(
response: &HttpResponse, response: &HttpResponse,
error: String, error: String,
w: &WebviewWindow<R>, w: &WebviewWindow<R>,
) -> Result<HttpResponse, String> { ) -> HttpResponse {
warn!("Failed to send request: {}", error); warn!("Failed to send request: {}", error);
let mut response = response.clone(); let mut response = response.clone();
response.elapsed = -1; response.state = HttpResponseState::Closed;
response.error = Some(error.clone()); response.error = Some(error.clone());
response = update_response_if_id(w, &response) response = update_response_if_id(w, &response)
.await .await
.expect("Failed to update response"); .expect("Failed to update response");
Ok(response) response
} }
#[tauri::command] #[tauri::command]
@@ -2182,13 +2191,16 @@ async fn call_frontend<T: Serialize + Clone, R: Runtime>(
let (tx, mut rx) = tokio::sync::watch::channel(PromptTextResponse::default()); let (tx, mut rx) = tokio::sync::watch::channel(PromptTextResponse::default());
let event_id = window.clone().listen(reply_id, move |ev| { let event_id = window.clone().listen(reply_id, move |ev| {
println!("GOT REPLY {ev:?}");
let resp: PromptTextResponse = serde_json::from_str(ev.payload()).unwrap(); 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 // 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); window.unlisten(event_id);
let foo = rx.borrow(); let foo = rx.borrow();

View File

@@ -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 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, }; 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 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<HttpResponseHeader>, 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<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; 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, }; export type KeyValue = { model: "key_value", createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };

View File

@@ -430,6 +430,21 @@ pub struct HttpResponseHeader {
pub value: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "models.ts")] #[ts(export, export_to = "models.ts")]
@@ -451,6 +466,7 @@ pub struct HttpResponse {
pub remote_addr: Option<String>, pub remote_addr: Option<String>,
pub status: i32, pub status: i32,
pub status_reason: Option<String>, pub status_reason: Option<String>,
pub state: HttpResponseState,
pub url: String, pub url: String,
pub version: Option<String>, pub version: Option<String>,
} }
@@ -475,6 +491,7 @@ pub enum HttpResponseIden {
RemoteAddr, RemoteAddr,
Status, Status,
StatusReason, StatusReason,
State,
Url, Url,
Version, Version,
} }
@@ -484,6 +501,7 @@ impl<'s> TryFrom<&Row<'s>> for HttpResponse {
fn try_from(r: &Row<'s>) -> Result<Self, Self::Error> { fn try_from(r: &Row<'s>) -> Result<Self, Self::Error> {
let headers: String = r.get("headers")?; let headers: String = r.get("headers")?;
let state: String = r.get("state")?;
Ok(HttpResponse { Ok(HttpResponse {
id: r.get("id")?, id: r.get("id")?,
model: r.get("model")?, model: r.get("model")?,
@@ -500,6 +518,7 @@ impl<'s> TryFrom<&Row<'s>> for HttpResponse {
remote_addr: r.get("remote_addr")?, remote_addr: r.get("remote_addr")?,
status: r.get("status")?, status: r.get("status")?,
status_reason: r.get("status_reason")?, status_reason: r.get("status_reason")?,
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
body_path: r.get("body_path")?, body_path: r.get("body_path")?,
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), 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)] #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "models.ts")] #[ts(export, export_to = "models.ts")]
@@ -615,6 +649,7 @@ pub struct GrpcConnection {
pub method: String, pub method: String,
pub service: String, pub service: String,
pub status: i32, pub status: i32,
pub state: GrpcConnectionState,
pub trailers: BTreeMap<String, String>, pub trailers: BTreeMap<String, String>,
pub url: String, pub url: String,
} }
@@ -634,6 +669,7 @@ pub enum GrpcConnectionIden {
Error, Error,
Method, Method,
Service, Service,
State,
Status, Status,
Trailers, Trailers,
Url, Url,
@@ -644,6 +680,7 @@ impl<'s> TryFrom<&Row<'s>> for GrpcConnection {
fn try_from(r: &Row<'s>) -> Result<Self, Self::Error> { fn try_from(r: &Row<'s>) -> Result<Self, Self::Error> {
let trailers: String = r.get("trailers")?; let trailers: String = r.get("trailers")?;
let state: String = r.get("state")?;
Ok(GrpcConnection { Ok(GrpcConnection {
id: r.get("id")?, id: r.get("id")?,
model: r.get("model")?, model: r.get("model")?,
@@ -654,6 +691,7 @@ impl<'s> TryFrom<&Row<'s>> for GrpcConnection {
service: r.get("service")?, service: r.get("service")?,
method: r.get("method")?, method: r.get("method")?,
elapsed: r.get("elapsed")?, elapsed: r.get("elapsed")?,
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
status: r.get("status")?, status: r.get("status")?,
url: r.get("url")?, url: r.get("url")?,
error: r.get("error")?, error: r.get("error")?,

View File

@@ -3,9 +3,10 @@ use std::fs;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden, GrpcConnection, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden, GrpcConnection,
GrpcConnectionIden, GrpcEvent, GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, GrpcConnectionIden, GrpcConnectionState, GrpcEvent, GrpcEventIden, GrpcRequest,
HttpRequestIden, HttpResponse, HttpResponseHeader, HttpResponseIden, KeyValue, KeyValueIden, GrpcRequestIden, HttpRequest, HttpRequestIden, HttpResponse, HttpResponseHeader,
ModelType, Plugin, PluginIden, Settings, SettingsIden, Workspace, WorkspaceIden, HttpResponseIden, HttpResponseState, KeyValue, KeyValueIden, ModelType, Plugin, PluginIden,
Settings, SettingsIden, Workspace, WorkspaceIden,
}; };
use crate::plugin::SqliteConnection; use crate::plugin::SqliteConnection;
use log::{debug, error}; use log::{debug, error};
@@ -433,7 +434,10 @@ pub async fn upsert_grpc_connection<R: Runtime>(
) -> Result<GrpcConnection> { ) -> Result<GrpcConnection> {
let connections = let connections =
list_http_responses_for_request(window, connection.request_id.as_str(), None).await?; 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); debug!("Deleting old grpc connection {}", c.id);
delete_grpc_connection(window, c.id.as_str()).await?; delete_grpc_connection(window, c.id.as_str()).await?;
} }
@@ -455,6 +459,7 @@ pub async fn upsert_grpc_connection<R: Runtime>(
GrpcConnectionIden::Service, GrpcConnectionIden::Service,
GrpcConnectionIden::Method, GrpcConnectionIden::Method,
GrpcConnectionIden::Elapsed, GrpcConnectionIden::Elapsed,
GrpcConnectionIden::State,
GrpcConnectionIden::Status, GrpcConnectionIden::Status,
GrpcConnectionIden::Error, GrpcConnectionIden::Error,
GrpcConnectionIden::Trailers, GrpcConnectionIden::Trailers,
@@ -469,6 +474,7 @@ pub async fn upsert_grpc_connection<R: Runtime>(
connection.service.as_str().into(), connection.service.as_str().into(),
connection.method.as_str().into(), connection.method.as_str().into(),
connection.elapsed.into(), connection.elapsed.into(),
serde_json::to_value(&connection.state)?.as_str().into(),
connection.status.into(), connection.status.into(),
connection.error.as_ref().map(|s| s.as_str()).into(), connection.error.as_ref().map(|s| s.as_str()).into(),
serde_json::to_string(&connection.trailers)?.into(), serde_json::to_string(&connection.trailers)?.into(),
@@ -1233,6 +1239,7 @@ pub async fn create_default_http_response<R: Runtime>(
0, 0,
0, 0,
"", "",
HttpResponseState::Initialized,
0, 0,
None, None,
None, None,
@@ -1251,6 +1258,7 @@ pub async fn create_http_response<R: Runtime>(
elapsed: i64, elapsed: i64,
elapsed_headers: i64, elapsed_headers: i64,
url: &str, url: &str,
state: HttpResponseState,
status: i64, status: i64,
status_reason: Option<&str>, status_reason: Option<&str>,
content_length: Option<i64>, content_length: Option<i64>,
@@ -1281,6 +1289,7 @@ pub async fn create_http_response<R: Runtime>(
HttpResponseIden::Elapsed, HttpResponseIden::Elapsed,
HttpResponseIden::ElapsedHeaders, HttpResponseIden::ElapsedHeaders,
HttpResponseIden::Url, HttpResponseIden::Url,
HttpResponseIden::State,
HttpResponseIden::Status, HttpResponseIden::Status,
HttpResponseIden::StatusReason, HttpResponseIden::StatusReason,
HttpResponseIden::ContentLength, HttpResponseIden::ContentLength,
@@ -1298,6 +1307,10 @@ pub async fn create_http_response<R: Runtime>(
elapsed.into(), elapsed.into(),
elapsed_headers.into(), elapsed_headers.into(),
url.into(), url.into(),
serde_json::to_value(state)?
.as_str()
.unwrap_or_default()
.into(),
status.into(), status.into(),
status_reason.into(), status_reason.into(),
content_length.into(), content_length.into(),
@@ -1318,10 +1331,11 @@ pub async fn cancel_pending_grpc_connections(app: &AppHandle) -> Result<()> {
let dbm = &*app.app_handle().state::<SqliteConnection>(); let dbm = &*app.app_handle().state::<SqliteConnection>();
let db = dbm.0.lock().await.get().unwrap(); let db = dbm.0.lock().await.get().unwrap();
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
let (sql, params) = Query::update() let (sql, params) = Query::update()
.table(GrpcConnectionIden::Table) .table(GrpcConnectionIden::Table)
.value(GrpcConnectionIden::Elapsed, -1) .values([(GrpcConnectionIden::State, closed.as_str().into())])
.cond_where(Expr::col(GrpcConnectionIden::Elapsed).eq(0)) .cond_where(Expr::col(GrpcConnectionIden::State).ne(closed.as_str()))
.build_rusqlite(SqliteQueryBuilder); .build_rusqlite(SqliteQueryBuilder);
db.execute(sql.as_str(), &*params.as_params())?; 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::<SqliteConnection>(); let dbm = &*app.app_handle().state::<SqliteConnection>();
let db = dbm.0.lock().await.get().unwrap(); let db = dbm.0.lock().await.get().unwrap();
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
let (sql, params) = Query::update() let (sql, params) = Query::update()
.table(HttpResponseIden::Table) .table(HttpResponseIden::Table)
.values([ .values([
(HttpResponseIden::Elapsed, (-1i32).into()), (HttpResponseIden::State, closed.as_str().into()),
(HttpResponseIden::StatusReason, "Cancelled".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); .build_rusqlite(SqliteQueryBuilder);
db.execute(sql.as_str(), &*params.as_params())?; db.execute(sql.as_str(), &*params.as_params())?;
@@ -1352,11 +1367,11 @@ pub async fn update_response_if_id<R: Runtime>(
if response.id.is_empty() { if response.id.is_empty() {
Ok(response.clone()) Ok(response.clone())
} else { } else {
update_response(window, response).await update_http_response(window, response).await
} }
} }
pub async fn update_response<R: Runtime>( pub async fn update_http_response<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
response: &HttpResponse, response: &HttpResponse,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
@@ -1397,6 +1412,10 @@ pub async fn update_response<R: Runtime>(
HttpResponseIden::Version, HttpResponseIden::Version,
response.version.as_ref().map(|s| s.as_str()).into(), response.version.as_ref().map(|s| s.as_str()).into(),
), ),
(
HttpResponseIden::State,
serde_json::to_value(&response.state)?.as_str().into(),
),
( (
HttpResponseIden::RemoteAddr, HttpResponseIden::RemoteAddr,
response.remote_addr.as_ref().map(|s| s.as_str()).into(), response.remote_addr.as_ref().map(|s| s.as_str()).into(),

View File

@@ -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 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<HttpResponseHeader>, 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<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, }; 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<EnvironmentVariable>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, variables: Array<EnvironmentVariable>, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest, HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import type { CSSProperties } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { memo, useCallback, useMemo } from 'react'; import React, { memo, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use'; import { useLocalStorage } from 'react-use';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders'; import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
@@ -88,6 +88,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
[activeResponse?.headers, contentType, setViewMode, viewMode], [activeResponse?.headers, contentType, setViewMode, viewMode],
); );
const isLoading = isResponseLoading(activeResponse);
return ( return (
<div <div
style={style} style={style}
@@ -103,10 +105,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
<HotKeyList <HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'urlBar.focus']} hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'urlBar.focus']}
/> />
) : isResponseLoading(activeResponse) ? (
<div className="h-full w-full flex items-center justify-center">
<Icon size="lg" className="opacity-disabled" spin icon="refresh" />
</div>
) : ( ) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1"> <div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack <HStack
@@ -119,27 +117,21 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
{activeResponse && ( {activeResponse && (
<HStack <HStack
space={2} space={2}
alignItems="center"
className={classNames( className={classNames(
'cursor-default select-none', 'cursor-default select-none',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm', 'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm',
)} )}
> >
{isLoading && <Icon size="sm" icon="refresh" spin />}
<StatusTag showReason response={activeResponse} /> <StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && ( <span>&bull;</span>
<> <DurationTag
<span>&bull;</span> headers={activeResponse.elapsedHeaders}
<DurationTag total={activeResponse.elapsed}
headers={activeResponse.elapsedHeaders} />
total={activeResponse.elapsed} <span>&bull;</span>
/> <SizeTag contentLength={activeResponse.contentLength ?? 0} />
</>
)}
{!!activeResponse.contentLength && (
<>
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength} />
</>
)}
<div className="ml-auto"> <div className="ml-auto">
<RecentResponsesDropdown <RecentResponsesDropdown
@@ -172,13 +164,13 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
<EmptyStateText>Empty Body</EmptyStateText> <EmptyStateText>Empty Body</EmptyStateText>
</div> </div>
) : contentType?.startsWith('image') ? ( ) : contentType?.startsWith('image') ? (
<ImageViewer className="pb-2" response={activeResponse} /> <EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
) : contentType?.startsWith('audio') ? ( ) : contentType?.startsWith('audio') ? (
<AudioViewer response={activeResponse} /> <EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
) : contentType?.startsWith('video') ? ( ) : contentType?.startsWith('video') ? (
<VideoViewer response={activeResponse} /> <EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
) : contentType?.match(/pdf/) ? ( ) : contentType?.match(/pdf/) ? (
<PdfViewer response={activeResponse} /> <EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
) : contentType?.match(/csv|tab-separated/) ? ( ) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} /> <CsvViewer className="pb-2" response={activeResponse} />
) : ( ) : (
@@ -204,3 +196,26 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
</div> </div>
); );
}); });
function EnsureCompleteResponse({
response,
render,
}: {
response: HttpResponse;
render: (v: { bodyPath: string }) => ReactNode;
}) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
// Wait until the response has been fully-downloaded
if (response.state !== 'closed') {
return (
<EmptyStateText>
<Icon icon="refresh" spin />
</EmptyStateText>
);
}
return render({ bodyPath: response.bodyPath });
}

View File

@@ -1,30 +1,34 @@
import classNames from 'classnames';
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
interface Props { interface Props {
response: Pick<HttpResponse, 'status' | 'statusReason' | 'error'>; response: HttpResponse;
className?: string; className?: string;
showReason?: boolean; showReason?: boolean;
} }
export function StatusTag({ response, className, showReason }: Props) { export function StatusTag({ response, className, showReason }: Props) {
const { status } = response; const { status, state } = response;
const label = status < 100 ? 'ERR' : status; const label = status < 100 ? 'ERROR' : status;
const category = `${status}`[0]; const category = `${status}`[0];
const isInitializing = state === 'initialized';
return ( return (
<span <span
className={classNames( className={classNames(
className, className,
'font-mono', 'font-mono',
category === '0' && 'text-danger', !isInitializing && category === '0' && 'text-danger',
category === '1' && 'text-info', !isInitializing && category === '1' && 'text-info',
category === '2' && 'text-success', !isInitializing && category === '2' && 'text-success',
category === '3' && 'text-primary', !isInitializing && category === '3' && 'text-primary',
category === '4' && 'text-warning', !isInitializing && category === '4' && 'text-warning',
category === '5' && 'text-danger', !isInitializing && category === '5' && 'text-danger',
isInitializing && 'text-text-subtle',
)} )}
> >
{label} {showReason && response.statusReason && response.statusReason} {isInitializing ? 'CONNECTING' : label}{' '}
{showReason && response.statusReason && response.statusReason}
</span> </span>
); );
} }

View File

@@ -1,17 +1,12 @@
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import React from 'react'; import React from 'react';
import type { HttpResponse } from '@yaakapp-internal/models';
interface Props { interface Props {
response: HttpResponse; bodyPath: string;
} }
export function AudioViewer({ response }: Props) { export function AudioViewer({ bodyPath }: Props) {
if (response.bodyPath === null) { const src = convertFileSrc(bodyPath);
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
return <audio className="w-full" controls src={src}></audio>; return <audio className="w-full" controls src={src}></audio>;

View File

@@ -3,7 +3,9 @@ import type { HttpResponse } from '@yaakapp-internal/models';
import { getContentTypeHeader } from '../../lib/model_util'; import { getContentTypeHeader } from '../../lib/model_util';
import { Banner } from '../core/Banner'; import { Banner } from '../core/Banner';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode'; import { InlineCode } from '../core/InlineCode';
import { EmptyStateText } from '../EmptyStateText';
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
@@ -12,6 +14,16 @@ interface Props {
export function BinaryViewer({ response }: Props) { export function BinaryViewer({ response }: Props) {
const saveResponse = useSaveResponse(response); const saveResponse = useSaveResponse(response);
const contentType = getContentTypeHeader(response.headers) ?? 'unknown'; const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
// Wait until the response has been fully-downloaded
if (response.state !== 'closed') {
return (
<EmptyStateText>
<Icon icon="refresh" spin />
</EmptyStateText>
);
}
return ( return (
<Banner color="primary" className="h-full flex flex-col gap-3"> <Banner color="primary" className="h-full flex flex-col gap-3">
<p> <p>

View File

@@ -1,7 +1,8 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders'; import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useResponseBodyText } from '../../hooks/useResponseBodyText'; 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 { BinaryViewer } from './BinaryViewer';
import { TextViewer } from './TextViewer'; import { TextViewer } from './TextViewer';
import { WebPageViewer } from './WebPageViewer'; import { WebPageViewer } from './WebPageViewer';
@@ -13,25 +14,34 @@ interface Props {
} }
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) { export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawBody = useResponseBodyText(response); const rawTextBody = useResponseBodyText(response);
let language = languageFromContentType(useContentTypeFromHeaders(response.headers)); 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 (rawTextBody.isLoading) {
if (language === 'html' && isJSON(rawBody.data ?? '')) {
language = 'json';
}
if (rawBody.isLoading) {
return null; return null;
} }
if (rawBody.data == null) { // Wasn't able to decode as text, so it must be binary
if (rawTextBody.data == null) {
return <BinaryViewer response={response} />; return <BinaryViewer response={response} />;
} }
if (language === 'html' && pretty) { if (language === 'html' && pretty) {
return <WebPageViewer response={response} />; return <WebPageViewer response={response} />;
} else { } else {
return <TextViewer response={response} pretty={pretty} className={textViewerClassName} />; return (
<TextViewer
language={language}
text={rawTextBody.data}
pretty={pretty}
className={textViewerClassName}
onSaveResponse={saveResponse.mutate}
responseId={response.id}
/>
);
} }
} }

View File

@@ -1,40 +1,11 @@
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import classNames from 'classnames'; import React from 'react';
import { useState } from 'react';
import type { HttpResponse } from '@yaakapp-internal/models';
interface Props { interface Props {
response: HttpResponse; bodyPath: string;
className?: string;
} }
export function ImageViewer({ response, className }: Props) { export function ImageViewer({ bodyPath }: Props) {
const bytes = response.contentLength ?? 0; const src = convertFileSrc(bodyPath);
const [show, setShow] = useState(bytes < 3 * 1000 * 1000); return <img src={src} alt="Response preview" className="max-w-full max-h-full pb-2" />;
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
if (!show) {
return (
<>
<div className="italic text-text-subtlest">
Response body is too large to preview.{' '}
<button className="cursor-pointer underline hover:text" onClick={() => setShow(true)}>
Show anyway
</button>
</div>
</>
);
}
return (
<img
src={src}
alt="Response preview"
className={classNames(className, 'max-w-full max-h-full')}
/>
);
} }

View File

@@ -2,15 +2,14 @@ import useResizeObserver from '@react-hook/resize-observer';
import 'react-pdf/dist/Page/TextLayer.css'; import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/AnnotationLayer.css';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import './PdfViewer.css';
import type { PDFDocumentProxy } from 'pdfjs-dist'; import type { PDFDocumentProxy } from 'pdfjs-dist';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Document, Page } from 'react-pdf'; import { Document, Page } from 'react-pdf';
import { useDebouncedState } from '../../hooks/useDebouncedState'; import { useDebouncedState } from '../../hooks/useDebouncedState';
import type { HttpResponse } from '@yaakapp-internal/models';
import './PdfViewer.css';
interface Props { interface Props {
response: HttpResponse; bodyPath: string;
} }
const options = { const options = {
@@ -18,7 +17,7 @@ const options = {
standardFontDataUrl: '/standard_fonts/', standardFontDataUrl: '/standard_fonts/',
}; };
export function PdfViewer({ response }: Props) { export function PdfViewer({ bodyPath }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useDebouncedState<number>(0, 100); const [containerWidth, setContainerWidth] = useDebouncedState<number>(0, 100);
const [numPages, setNumPages] = useState<number>(); const [numPages, setNumPages] = useState<number>();
@@ -31,11 +30,7 @@ export function PdfViewer({ response }: Props) {
setNumPages(nextNumPages); setNumPages(nextNumPages);
}; };
if (response.bodyPath === null) { const src = convertFileSrc(bodyPath);
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
return ( return (
<div ref={containerRef} className="w-full h-full overflow-y-auto"> <div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document <Document

View File

@@ -1,19 +1,16 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { createGlobalState } from 'react-use'; import { createGlobalState } from 'react-use';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders'; import { useCopy } from '../../hooks/useCopy';
import { useDebouncedValue } from '../../hooks/useDebouncedValue'; import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useFilterResponse } from '../../hooks/useFilterResponse'; import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useSaveResponse } from '../../hooks/useSaveResponse';
import { useToggle } from '../../hooks/useToggle'; import { useToggle } from '../../hooks/useToggle';
import { isJSON, languageFromContentType } from '../../lib/contentType';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters'; import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import { CopyButton } from '../CopyButton'; import { CopyButton } from '../CopyButton';
import { Banner } from '../core/Banner'; import { Banner } from '../core/Banner';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor';
import { Editor } from '../core/Editor'; import { Editor } from '../core/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension'; import { hyperlink } from '../core/Editor/hyperlink/extension';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
@@ -21,46 +18,43 @@ import { InlineCode } from '../core/InlineCode';
import { Input } from '../core/Input'; import { Input } from '../core/Input';
import { SizeTag } from '../core/SizeTag'; import { SizeTag } from '../core/SizeTag';
import { HStack } from '../core/Stacks'; import { HStack } from '../core/Stacks';
import { BinaryViewer } from './BinaryViewer';
const extraExtensions = [hyperlink]; const extraExtensions = [hyperlink];
const LARGE_RESPONSE_BYTES = 2 * 1000 * 1000; const LARGE_RESPONSE_BYTES = 2 * 1000 * 1000;
interface Props { interface Props {
response: HttpResponse;
pretty: boolean; pretty: boolean;
className?: string; className?: string;
text: string;
language: EditorProps['language'];
responseId: string;
onSaveResponse: () => void;
} }
const useFilterText = createGlobalState<Record<string, string | null>>({}); const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ response, pretty, className }: Props) { export function TextViewer({
language,
text,
responseId,
pretty,
className,
onSaveResponse,
}: Props) {
const [filterTextMap, setFilterTextMap] = useFilterText(); const [filterTextMap, setFilterTextMap] = useFilterText();
const [showLargeResponse, toggleShowLargeResponse] = useToggle(); const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const filterText = filterTextMap[response.id] ?? null; const filterText = filterTextMap[responseId] ?? null;
const copy = useCopy();
const debouncedFilterText = useDebouncedValue(filterText, 200); const debouncedFilterText = useDebouncedValue(filterText, 200);
const setFilterText = useCallback( const setFilterText = useCallback(
(v: string | null) => { (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 isSearching = filterText != null;
const filteredResponse = useFilterResponse({ filter: debouncedFilterText ?? '', responseId });
const filteredResponse = useFilterResponse({
filter: debouncedFilterText ?? '',
responseId: response.id,
});
const toggleSearch = useCallback(() => { const toggleSearch = useCallback(() => {
if (isSearching) { if (isSearching) {
@@ -81,7 +75,7 @@ export function TextViewer({ response, pretty, className }: Props) {
nodes.push( nodes.push(
<div key="input" className="w-full !opacity-100"> <div key="input" className="w-full !opacity-100">
<Input <Input
key={response.id} key={responseId}
validate={!filteredResponse.error} validate={!filteredResponse.error}
hideLabel hideLabel
autoFocus autoFocus
@@ -116,20 +110,12 @@ export function TextViewer({ response, pretty, className }: Props) {
filteredResponse.error, filteredResponse.error,
isSearching, isSearching,
language, language,
response.id, responseId,
setFilterText, setFilterText,
toggleSearch, toggleSearch,
]); ]);
if (rawBody.isLoading) { if (!showLargeResponse && text.length > LARGE_RESPONSE_BYTES) {
return null;
}
if (rawBody.data == null) {
return <BinaryViewer response={response} />;
}
if (!showLargeResponse && (response.contentLength ?? 0) > LARGE_RESPONSE_BYTES) {
return ( return (
<Banner color="primary" className="h-full flex flex-col gap-3"> <Banner color="primary" className="h-full flex flex-col gap-3">
<p> <p>
@@ -143,15 +129,10 @@ export function TextViewer({ response, pretty, className }: Props) {
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}> <Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Response Reveal Response
</Button> </Button>
<Button variant="border" size="xs" onClick={() => saveResponse.mutate()}> <Button variant="border" size="xs" onClick={onSaveResponse}>
Save to File Save to File
</Button> </Button>
<CopyButton <CopyButton variant="border" size="xs" onClick={() => copy(text)} text={text} />
variant="border"
size="xs"
onClick={() => saveResponse.mutate()}
text={rawBody.data}
/>
</HStack> </HStack>
</Banner> </Banner>
); );
@@ -159,10 +140,10 @@ export function TextViewer({ response, pretty, className }: Props) {
const formattedBody = const formattedBody =
pretty && language === 'json' pretty && language === 'json'
? tryFormatJson(rawBody.data) ? tryFormatJson(text)
: pretty && (language === 'xml' || language === 'html') : pretty && (language === 'xml' || language === 'html')
? tryFormatXml(rawBody.data) ? tryFormatXml(text)
: rawBody.data; : text;
let body; let body;
if (isSearching && filterText?.length > 0) { if (isSearching && filterText?.length > 0) {

View File

@@ -1,17 +1,12 @@
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import React from 'react'; import React from 'react';
import type { HttpResponse } from '@yaakapp-internal/models';
interface Props { interface Props {
response: HttpResponse; bodyPath: string;
} }
export function VideoViewer({ response }: Props) { export function VideoViewer({ bodyPath }: Props) {
if (response.bodyPath === null) { const src = convertFileSrc(bodyPath);
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src}></video>; return <video className="w-full" controls src={src}></video>;

View File

@@ -4,7 +4,8 @@ import { getResponseBodyText } from '../lib/responseBody';
export function useResponseBodyText(response: HttpResponse) { export function useResponseBodyText(response: HttpResponse) {
return useQuery<string | null>({ return useQuery<string | null>({
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), queryFn: () => getResponseBodyText(response),
}); });
} }

View File

@@ -1,26 +1,34 @@
import type { EditorProps } from '../components/core/Editor'; 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 ?? ''; const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType.includes('json')) { if (justContentType.includes('json')) {
return 'json'; return 'json';
} else if (justContentType.includes('xml')) { } else if (justContentType.includes('xml')) {
return 'xml'; return 'xml';
} else if (justContentType.includes('html')) { } else if (justContentType.includes('html')) {
return 'html'; return detectFromContent(content);
} else if (justContentType.includes('javascript')) { } else if (justContentType.includes('javascript')) {
return 'javascript'; return 'javascript';
} else {
return 'text';
} }
return detectFromContent(content);
} }
export function isJSON(text: string): boolean { export function isJSON(text: string): boolean {
try { return text.startsWith('{') || text.startsWith('[');
JSON.parse(text); }
return true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars function detectFromContent(content: string | null): EditorProps['language'] {
} catch (err) { if (content == null) return 'text';
return false;
} if (content.startsWith('{') || content.startsWith('[')) {
return 'json';
} else if (content.startsWith('<!DOCTYPE') || content.startsWith('<html')) {
return 'html';
}
return 'text';
} }

View File

@@ -32,8 +32,11 @@ export function cookieDomain(cookie: Cookie): string {
return 'unknown'; return 'unknown';
} }
export function isResponseLoading(response: HttpResponse | GrpcConnection): boolean { export function isResponseLoading(
return response.elapsed === 0; response: Pick<HttpResponse | GrpcConnection, 'state'> | null,
): boolean {
if (response == null) return false;
return response.state !== 'closed';
} }
export function modelsEq(a: AnyModel, b: AnyModel) { export function modelsEq(a: AnyModel, b: AnyModel) {