mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-22 00:28:30 +02:00
Response Streaming (#124)
This commit is contained in:
@@ -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, };
|
||||||
|
|||||||
5
src-tauri/migrations/20241003134208_response-state.sql
Normal file
5
src-tauri/migrations/20241003134208_response-state.sql
Normal 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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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, };
|
||||||
|
|||||||
@@ -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")?,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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, };
|
||||||
|
|||||||
@@ -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>•</span>
|
||||||
<>
|
<DurationTag
|
||||||
<span>•</span>
|
headers={activeResponse.elapsedHeaders}
|
||||||
<DurationTag
|
total={activeResponse.elapsed}
|
||||||
headers={activeResponse.elapsedHeaders}
|
/>
|
||||||
total={activeResponse.elapsed}
|
<span>•</span>
|
||||||
/>
|
<SizeTag contentLength={activeResponse.contentLength ?? 0} />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!!activeResponse.contentLength && (
|
|
||||||
<>
|
|
||||||
<span>•</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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user