mirror of
https://github.com/mountain-loop/yaak.git
synced 2025-12-23 22:48:55 -05:00
Http response events (#326)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ out
|
||||
.tmp
|
||||
tmp
|
||||
.zed
|
||||
codebook.toml
|
||||
|
||||
@@ -19,8 +19,8 @@ use yaak_http::sender::ReqwestSender;
|
||||
use yaak_http::transaction::HttpTransaction;
|
||||
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params};
|
||||
use yaak_models::models::{
|
||||
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpResponseState,
|
||||
ProxySetting, ProxySettingAuth,
|
||||
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
|
||||
HttpResponseState, ProxySetting, ProxySettingAuth,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManagerExt;
|
||||
use yaak_models::util::UpdateSource;
|
||||
@@ -271,10 +271,30 @@ async fn execute_transaction<R: Runtime>(
|
||||
app_handle.db().update_http_response_if_id(&r, &update_source)?;
|
||||
}
|
||||
|
||||
// Create channel for receiving events and spawn a task to store them in DB
|
||||
let (event_tx, mut event_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<yaak_http::sender::HttpResponseEvent>();
|
||||
|
||||
// Write events to DB in a task
|
||||
{
|
||||
let response_id = response_id.clone();
|
||||
let workspace_id = response.lock().await.workspace_id.clone();
|
||||
let app_handle = app_handle.clone();
|
||||
let update_source = update_source.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
|
||||
let _ = app_handle.db().upsert(&db_event, &update_source);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Execute the transaction with cancellation support
|
||||
// This returns the response with headers, but body is not yet consumed
|
||||
let (mut http_response, _events) =
|
||||
transaction.execute_with_cancellation(sendable_request, cancelled_rx.clone()).await?;
|
||||
// Events (headers, settings, chunks) are sent through the channel
|
||||
let mut http_response = transaction
|
||||
.execute_with_cancellation(sendable_request, cancelled_rx.clone(), event_tx)
|
||||
.await?;
|
||||
|
||||
// Prepare the response path before consuming the body
|
||||
let dir = app_handle.path().app_data_dir()?;
|
||||
|
||||
@@ -34,8 +34,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, Workspace,
|
||||
WorkspaceMeta,
|
||||
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState,
|
||||
Plugin, Workspace, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManagerExt;
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
||||
@@ -830,6 +830,17 @@ async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>>
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_get_http_response_events<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<Vec<HttpResponseEvent>> {
|
||||
use yaak_models::models::HttpResponseEventIden;
|
||||
let events: Vec<HttpResponseEvent> =
|
||||
app_handle.db().find_many(HttpResponseEventIden::ResponseId, response_id, None)?;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
@@ -1462,6 +1473,7 @@ pub fn run() {
|
||||
cmd_get_http_authentication_summaries,
|
||||
cmd_get_http_authentication_config,
|
||||
cmd_get_sse_events,
|
||||
cmd_get_http_response_events,
|
||||
cmd_get_workspace_meta,
|
||||
cmd_grpc_go,
|
||||
cmd_grpc_reflect,
|
||||
|
||||
@@ -7,20 +7,45 @@ use reqwest::{Client, Method, Version};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, BufReader};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RedirectBehavior {
|
||||
/// 307/308: Method and body are preserved
|
||||
Preserve,
|
||||
/// 303 or 301/302 with POST: Method changed to GET, body dropped
|
||||
DropBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HttpResponseEvent {
|
||||
Setting(String, String),
|
||||
Info(String),
|
||||
SendUrl { method: String, path: String },
|
||||
ReceiveUrl { version: Version, status: String },
|
||||
Redirect {
|
||||
url: String,
|
||||
status: u16,
|
||||
behavior: RedirectBehavior,
|
||||
},
|
||||
SendUrl {
|
||||
method: String,
|
||||
path: String,
|
||||
},
|
||||
ReceiveUrl {
|
||||
version: Version,
|
||||
status: String,
|
||||
},
|
||||
HeaderUp(String, String),
|
||||
HeaderDown(String, String),
|
||||
HeaderUpDone,
|
||||
HeaderDownDone,
|
||||
ChunkSent {
|
||||
bytes: usize,
|
||||
},
|
||||
ChunkReceived {
|
||||
bytes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Display for HttpResponseEvent {
|
||||
@@ -28,14 +53,47 @@ impl Display for HttpResponseEvent {
|
||||
match self {
|
||||
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
||||
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
||||
HttpResponseEvent::Redirect { url, status, behavior } => {
|
||||
let behavior_str = match behavior {
|
||||
RedirectBehavior::Preserve => "preserve",
|
||||
RedirectBehavior::DropBody => "drop body",
|
||||
};
|
||||
write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str)
|
||||
}
|
||||
HttpResponseEvent::SendUrl { method, path } => write!(f, "> {} {}", method, path),
|
||||
HttpResponseEvent::ReceiveUrl { version, status } => {
|
||||
write!(f, "< {} {}", version_to_str(version), status)
|
||||
}
|
||||
HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value),
|
||||
HttpResponseEvent::HeaderUpDone => write!(f, ">"),
|
||||
HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value),
|
||||
HttpResponseEvent::HeaderDownDone => write!(f, "<"),
|
||||
HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes),
|
||||
HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
|
||||
fn from(event: HttpResponseEvent) -> Self {
|
||||
use yaak_models::models::HttpResponseEventData as D;
|
||||
match event {
|
||||
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
||||
HttpResponseEvent::Info(message) => D::Info { message },
|
||||
HttpResponseEvent::Redirect { url, status, behavior } => D::Redirect {
|
||||
url,
|
||||
status,
|
||||
behavior: match behavior {
|
||||
RedirectBehavior::Preserve => "preserve".to_string(),
|
||||
RedirectBehavior::DropBody => "drop_body".to_string(),
|
||||
},
|
||||
},
|
||||
HttpResponseEvent::SendUrl { method, path } => D::SendUrl { method, path },
|
||||
HttpResponseEvent::ReceiveUrl { version, status } => {
|
||||
D::ReceiveUrl { version: format!("{:?}", version), status }
|
||||
}
|
||||
HttpResponseEvent::HeaderUp(name, value) => D::HeaderUp { name, value },
|
||||
HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value },
|
||||
HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes },
|
||||
HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +107,40 @@ pub struct BodyStats {
|
||||
pub size_decompressed: u64,
|
||||
}
|
||||
|
||||
/// An AsyncRead wrapper that sends chunk events as data is read
|
||||
pub struct TrackingRead<R> {
|
||||
inner: R,
|
||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||
ended: bool,
|
||||
}
|
||||
|
||||
impl<R> TrackingRead<R> {
|
||||
pub fn new(inner: R, event_tx: mpsc::UnboundedSender<HttpResponseEvent>) -> Self {
|
||||
Self { inner, event_tx, ended: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for TrackingRead<R> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
let before = buf.filled().len();
|
||||
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &result {
|
||||
let bytes_read = buf.filled().len() - before;
|
||||
if bytes_read > 0 {
|
||||
// Ignore send errors - receiver may have been dropped
|
||||
let _ = self.event_tx.send(HttpResponseEvent::ChunkReceived { bytes: bytes_read });
|
||||
} else if !self.ended {
|
||||
self.ended = true;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias for the body stream
|
||||
type BodyStream = Pin<Box<dyn AsyncRead + Send>>;
|
||||
|
||||
@@ -215,10 +307,11 @@ impl HttpResponse {
|
||||
pub trait HttpSender: Send + Sync {
|
||||
/// Send an HTTP request and return the response with headers.
|
||||
/// The body is not consumed until you call bytes(), text(), write_to_file(), or drain().
|
||||
/// Events are sent through the provided channel.
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
events: &mut Vec<HttpResponseEvent>,
|
||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse>;
|
||||
}
|
||||
|
||||
@@ -245,8 +338,13 @@ impl HttpSender for ReqwestSender {
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
events: &mut Vec<HttpResponseEvent>,
|
||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
// Helper to send events (ignores errors if receiver is dropped)
|
||||
let send_event = |event: HttpResponseEvent| {
|
||||
let _ = event_tx.send(event);
|
||||
};
|
||||
|
||||
// Parse the HTTP method
|
||||
let method = Method::from_bytes(request.method.as_bytes())
|
||||
.map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?;
|
||||
@@ -282,7 +380,7 @@ impl HttpSender for ReqwestSender {
|
||||
|
||||
// Send the request
|
||||
let sendable_req = req_builder.build()?;
|
||||
events.push(HttpResponseEvent::Setting(
|
||||
send_event(HttpResponseEvent::Setting(
|
||||
"timeout".to_string(),
|
||||
if request.options.timeout.unwrap_or_default().is_zero() {
|
||||
"Infinity".to_string()
|
||||
@@ -291,7 +389,7 @@ impl HttpSender for ReqwestSender {
|
||||
},
|
||||
));
|
||||
|
||||
events.push(HttpResponseEvent::SendUrl {
|
||||
send_event(HttpResponseEvent::SendUrl {
|
||||
path: sendable_req.url().path().to_string(),
|
||||
method: sendable_req.method().to_string(),
|
||||
});
|
||||
@@ -300,10 +398,9 @@ impl HttpSender for ReqwestSender {
|
||||
for (name, value) in sendable_req.headers() {
|
||||
let v = value.to_str().unwrap_or_default().to_string();
|
||||
request_headers.insert(name.to_string(), v.clone());
|
||||
events.push(HttpResponseEvent::HeaderUp(name.to_string(), v));
|
||||
send_event(HttpResponseEvent::HeaderUp(name.to_string(), v));
|
||||
}
|
||||
events.push(HttpResponseEvent::HeaderUpDone);
|
||||
events.push(HttpResponseEvent::Info("Sending request to server".to_string()));
|
||||
send_event(HttpResponseEvent::Info("Sending request to server".to_string()));
|
||||
|
||||
// Map some errors to our own, so they look nicer
|
||||
let response = self.client.execute(sendable_req).await.map_err(|e| {
|
||||
@@ -323,7 +420,7 @@ impl HttpSender for ReqwestSender {
|
||||
let version = Some(version_to_str(&response.version()));
|
||||
let content_length = response.content_length();
|
||||
|
||||
events.push(HttpResponseEvent::ReceiveUrl {
|
||||
send_event(HttpResponseEvent::ReceiveUrl {
|
||||
version: response.version(),
|
||||
status: response.status().to_string(),
|
||||
});
|
||||
@@ -332,11 +429,10 @@ impl HttpSender for ReqwestSender {
|
||||
let mut headers = HashMap::new();
|
||||
for (key, value) in response.headers() {
|
||||
if let Ok(v) = value.to_str() {
|
||||
events.push(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string()));
|
||||
send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string()));
|
||||
headers.insert(key.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
events.push(HttpResponseEvent::HeaderDownDone);
|
||||
|
||||
// Determine content encoding for decompression
|
||||
// HTTP headers are case-insensitive, so we need to search for any casing
|
||||
@@ -355,7 +451,9 @@ impl HttpSender for ReqwestSender {
|
||||
byte_stream.map(|result| result.map_err(|e| std::io::Error::other(e))),
|
||||
);
|
||||
|
||||
let body_stream: BodyStream = Box::pin(stream_reader);
|
||||
// Wrap the stream with tracking to emit chunk received events via the same channel
|
||||
let tracking_reader = TrackingRead::new(stream_reader, event_tx);
|
||||
let body_stream: BodyStream = Box::pin(tracking_reader);
|
||||
|
||||
Ok(HttpResponse::new(
|
||||
status,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::error::Result;
|
||||
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender};
|
||||
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};
|
||||
use crate::types::SendableHttpRequest;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::watch::Receiver;
|
||||
|
||||
/// HTTP Transaction that manages the lifecycle of a request, including redirect handling
|
||||
@@ -22,17 +23,23 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
|
||||
/// Execute the request with cancellation support.
|
||||
/// Returns an HttpResponse with unconsumed body - caller decides how to consume it.
|
||||
/// Events are sent through the provided channel.
|
||||
pub async fn execute_with_cancellation(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
mut cancelled_rx: Receiver<bool>,
|
||||
) -> Result<(HttpResponse, Vec<HttpResponseEvent>)> {
|
||||
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
let mut redirect_count = 0;
|
||||
let mut current_url = request.url;
|
||||
let mut current_method = request.method;
|
||||
let mut current_headers = request.headers;
|
||||
let mut current_body = request.body;
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Helper to send events (ignores errors if receiver is dropped)
|
||||
let send_event = |event: HttpResponseEvent| {
|
||||
let _ = event_tx.send(event);
|
||||
};
|
||||
|
||||
loop {
|
||||
// Check for cancellation before each request
|
||||
@@ -50,14 +57,14 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
};
|
||||
|
||||
// Send the request
|
||||
events.push(HttpResponseEvent::Setting(
|
||||
send_event(HttpResponseEvent::Setting(
|
||||
"redirects".to_string(),
|
||||
request.options.follow_redirects.to_string(),
|
||||
));
|
||||
|
||||
// Execute with cancellation support
|
||||
let response = tokio::select! {
|
||||
result = self.sender.send(req, &mut events) => result?,
|
||||
result = self.sender.send(req, event_tx.clone()) => result?,
|
||||
_ = cancelled_rx.changed() => {
|
||||
return Err(crate::error::Error::RequestCanceledError);
|
||||
}
|
||||
@@ -65,12 +72,12 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
|
||||
if !Self::is_redirect(response.status) {
|
||||
// Not a redirect - return the response for caller to consume body
|
||||
return Ok((response, events));
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if !request.options.follow_redirects {
|
||||
// Redirects disabled - return the redirect response as-is
|
||||
return Ok((response, events));
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Check if we've exceeded max redirects
|
||||
@@ -99,7 +106,7 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
// Also get status before draining
|
||||
let status = response.status;
|
||||
|
||||
events.push(HttpResponseEvent::Info("Ignoring the response body".to_string()));
|
||||
send_event(HttpResponseEvent::Info("Ignoring the response body".to_string()));
|
||||
|
||||
// Drain the redirect response body before following
|
||||
response.drain().await?;
|
||||
@@ -118,38 +125,36 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
format!("{}/{}", base_path, location)
|
||||
};
|
||||
|
||||
events.push(HttpResponseEvent::Info(format!(
|
||||
"Issuing redirect {} to: {}",
|
||||
redirect_count + 1,
|
||||
current_url
|
||||
)));
|
||||
// Determine redirect behavior based on status code and method
|
||||
let behavior = if status == 303 {
|
||||
// 303 See Other always changes to GET
|
||||
RedirectBehavior::DropBody
|
||||
} else if (status == 301 || status == 302) && current_method == "POST" {
|
||||
// For 301/302, change POST to GET (common browser behavior)
|
||||
RedirectBehavior::DropBody
|
||||
} else {
|
||||
// For 307 and 308, the method and body are preserved
|
||||
// Also for 301/302 with non-POST methods
|
||||
RedirectBehavior::Preserve
|
||||
};
|
||||
|
||||
send_event(HttpResponseEvent::Redirect {
|
||||
url: current_url.clone(),
|
||||
status,
|
||||
behavior: behavior.clone(),
|
||||
});
|
||||
|
||||
// Handle method changes for certain redirect codes
|
||||
if status == 303 {
|
||||
// 303 See Other always changes to GET
|
||||
if matches!(behavior, RedirectBehavior::DropBody) {
|
||||
if current_method != "GET" {
|
||||
current_method = "GET".to_string();
|
||||
events.push(HttpResponseEvent::Info("Changing method to GET".to_string()));
|
||||
}
|
||||
// Remove content-related headers
|
||||
current_headers.retain(|h| {
|
||||
let name_lower = h.0.to_lowercase();
|
||||
!name_lower.starts_with("content-") && name_lower != "transfer-encoding"
|
||||
});
|
||||
} else if status == 301 || status == 302 {
|
||||
// For 301/302, change POST to GET (common browser behavior)
|
||||
// but keep other methods as-is
|
||||
if current_method == "POST" {
|
||||
events.push(HttpResponseEvent::Info("Changing method to GET".to_string()));
|
||||
current_method = "GET".to_string();
|
||||
// Remove content-related headers
|
||||
current_headers.retain(|h| {
|
||||
let name_lower = h.0.to_lowercase();
|
||||
!name_lower.starts_with("content-") && name_lower != "transfer-encoding"
|
||||
});
|
||||
}
|
||||
}
|
||||
// For 307 and 308, the method and body are preserved
|
||||
|
||||
// Reset body for next iteration (since it was moved in the send call)
|
||||
// For redirects that change method to GET or for all redirects since body was consumed
|
||||
@@ -231,7 +236,7 @@ mod tests {
|
||||
async fn send(
|
||||
&self,
|
||||
_request: SendableHttpRequest,
|
||||
_events: &mut Vec<HttpResponseEvent>,
|
||||
_event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
let mut responses = self.responses.lock().await;
|
||||
if responses.is_empty() {
|
||||
@@ -271,7 +276,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap();
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
// Consume the body to verify it
|
||||
@@ -303,7 +309,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap();
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
let (body, _) = result.bytes().await.unwrap();
|
||||
@@ -334,7 +341,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let result = transaction.execute_with_cancellation(request, rx).await;
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||
if let Err(crate::error::Error::RequestError(msg)) = result {
|
||||
assert!(msg.contains("Maximum redirect limit"));
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||
|
||||
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||
|
||||
@@ -40,6 +40,15 @@ export type HttpRequestHeader = { enabled?: boolean, name: string, value: string
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "start_request" } | { "type": "end_request" } | { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
@@ -15,6 +15,7 @@ export const grpcEventsAtom = createOrderedModelAtom('grpc_event', 'createdAt',
|
||||
export const grpcRequestsAtom = createModelAtom('grpc_request');
|
||||
export const httpRequestsAtom = createModelAtom('http_request');
|
||||
export const httpResponsesAtom = createOrderedModelAtom('http_response', 'createdAt', 'desc');
|
||||
export const httpResponseEventsAtom = createOrderedModelAtom('http_response_event', 'createdAt', 'asc');
|
||||
export const keyValuesAtom = createModelAtom('key_value');
|
||||
export const pluginsAtom = createModelAtom('plugin');
|
||||
export const settingsAtom = createSingularModelAtom('settings');
|
||||
|
||||
@@ -11,6 +11,7 @@ export function newStoreData(): ModelStoreData {
|
||||
grpc_request: {},
|
||||
http_request: {},
|
||||
http_response: {},
|
||||
http_response_event: {},
|
||||
key_value: {},
|
||||
plugin: {},
|
||||
settings: {},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE http_response_events
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'http_response_event' NOT NULL,
|
||||
workspace_id TEXT NOT NULL
|
||||
REFERENCES workspaces
|
||||
ON DELETE CASCADE,
|
||||
response_id TEXT NOT NULL
|
||||
REFERENCES http_responses
|
||||
ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,
|
||||
event TEXT NOT NULL
|
||||
);
|
||||
@@ -1439,6 +1439,143 @@ impl UpsertModelInfo for HttpResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable representation of HTTP response events for DB storage.
|
||||
/// This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
/// The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum HttpResponseEventData {
|
||||
Setting {
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
Info {
|
||||
message: String,
|
||||
},
|
||||
Redirect {
|
||||
url: String,
|
||||
status: u16,
|
||||
behavior: String,
|
||||
},
|
||||
SendUrl {
|
||||
method: String,
|
||||
path: String,
|
||||
},
|
||||
ReceiveUrl {
|
||||
version: String,
|
||||
status: String,
|
||||
},
|
||||
HeaderUp {
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
HeaderDown {
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
ChunkSent {
|
||||
bytes: usize,
|
||||
},
|
||||
ChunkReceived {
|
||||
bytes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for HttpResponseEventData {
|
||||
fn default() -> Self {
|
||||
Self::Info { message: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "http_response_events")]
|
||||
pub struct HttpResponseEvent {
|
||||
#[ts(type = "\"http_response_event\"")]
|
||||
pub model: String,
|
||||
pub id: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub workspace_id: String,
|
||||
pub response_id: String,
|
||||
pub event: HttpResponseEventData,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for HttpResponseEvent {
|
||||
fn table_name() -> impl IntoTableRef + IntoIden {
|
||||
HttpResponseEventIden::Table
|
||||
}
|
||||
|
||||
fn id_column() -> impl IntoIden + Eq + Clone {
|
||||
HttpResponseEventIden::Id
|
||||
}
|
||||
|
||||
fn generate_id() -> String {
|
||||
generate_prefixed_id("re")
|
||||
}
|
||||
|
||||
fn order_by() -> (impl IntoColumnRef, Order) {
|
||||
(HttpResponseEventIden::CreatedAt, Order::Asc)
|
||||
}
|
||||
|
||||
fn get_id(&self) -> String {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn insert_values(
|
||||
self,
|
||||
source: &UpdateSource,
|
||||
) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
||||
use HttpResponseEventIden::*;
|
||||
Ok(vec![
|
||||
(CreatedAt, upsert_date(source, self.created_at)),
|
||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
||||
(WorkspaceId, self.workspace_id.into()),
|
||||
(ResponseId, self.response_id.into()),
|
||||
(Event, serde_json::to_string(&self.event)?.into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn update_columns() -> Vec<impl IntoIden> {
|
||||
vec![
|
||||
HttpResponseEventIden::UpdatedAt,
|
||||
HttpResponseEventIden::Event,
|
||||
]
|
||||
}
|
||||
|
||||
fn from_row(r: &Row) -> rusqlite::Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let event: String = r.get("event")?;
|
||||
Ok(Self {
|
||||
id: r.get("id")?,
|
||||
model: r.get("model")?,
|
||||
workspace_id: r.get("workspace_id")?,
|
||||
response_id: r.get("response_id")?,
|
||||
created_at: r.get("created_at")?,
|
||||
updated_at: r.get("updated_at")?,
|
||||
event: serde_json::from_str(&event).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpResponseEvent {
|
||||
pub fn new(response_id: &str, workspace_id: &str, event: HttpResponseEventData) -> Self {
|
||||
Self {
|
||||
model: "http_response_event".to_string(),
|
||||
id: Self::generate_id(),
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
response_id: response_id.to_string(),
|
||||
event,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
@@ -2189,6 +2326,7 @@ define_any_model! {
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseEvent,
|
||||
KeyValue,
|
||||
Plugin,
|
||||
Settings,
|
||||
|
||||
@@ -208,6 +208,7 @@ impl TryFrom<AnyModel> for SyncModel {
|
||||
AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)),
|
||||
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
|
||||
AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)),
|
||||
AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)),
|
||||
AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)),
|
||||
AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),
|
||||
AnyModel::Settings(m) => return Err(UnknownModel(m.model)),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ComponentType, CSSProperties } from 'react';
|
||||
import { lazy, Suspense, useCallback, useMemo } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||
@@ -23,6 +24,7 @@ import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
||||
import { ResponseEvents } from './ResponseEvents';
|
||||
import { ResponseHeaders } from './ResponseHeaders';
|
||||
import { ResponseInfo } from './ResponseInfo';
|
||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||
@@ -46,6 +48,7 @@ interface Props {
|
||||
const TAB_BODY = 'body';
|
||||
const TAB_HEADERS = 'headers';
|
||||
const TAB_INFO = 'info';
|
||||
const TAB_EVENTS = 'events';
|
||||
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
@@ -57,12 +60,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
label: 'Preview Mode',
|
||||
hidden: (activeResponse?.contentLength || 0) === 0,
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: setViewMode,
|
||||
@@ -82,6 +86,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_EVENTS,
|
||||
label: 'Timeline',
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
},
|
||||
{
|
||||
value: TAB_INFO,
|
||||
label: 'Info',
|
||||
@@ -93,7 +102,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
setViewMode,
|
||||
viewMode,
|
||||
activeResponse?.requestHeaders.length,
|
||||
activeResponse?.contentLength,
|
||||
responseEvents.data?.length,
|
||||
],
|
||||
);
|
||||
const activeTab = activeTabs?.[activeRequestId];
|
||||
@@ -224,6 +233,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
<TabContent value={TAB_INFO}>
|
||||
<ResponseInfo response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_EVENTS}>
|
||||
<ResponseEvents response={activeResponse} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
341
src-web/components/ResponseEvents.tsx
Normal file
341
src-web/components/ResponseEvents.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import type {
|
||||
HttpResponse,
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { format } from 'date-fns';
|
||||
import { Fragment, type ReactNode, useMemo, useState } from 'react';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { AutoScroller } from './core/AutoScroller';
|
||||
import { Banner } from './core/Banner';
|
||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||
import { Icon, type IconProps } from './core/Icon';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
import { Separator } from './core/Separator';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function ResponseEvents({ response }: Props) {
|
||||
return (
|
||||
<Fragment key={response.id}>
|
||||
<ActualResponseEvents response={response} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ActualResponseEvents({ response }: Props) {
|
||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
||||
|
||||
const activeEvent = useMemo(
|
||||
() => (activeEventIndex == null ? null : events?.[activeEventIndex]),
|
||||
[activeEventIndex, events],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-3 text-text-subtlest italic">Loading events...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Banner color="danger" className="m-3">
|
||||
{String(error)}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
return <div className="p-3 text-text-subtlest italic">No events recorded</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
name="http_response_events"
|
||||
defaultRatio={0.5}
|
||||
minHeightPx={20}
|
||||
firstSlot={() => (
|
||||
<AutoScroller
|
||||
data={events}
|
||||
render={(event, i) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
isActive={i === activeEventIndex}
|
||||
onClick={() => {
|
||||
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||
else setActiveEventIndex(i);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
secondSlot={
|
||||
activeEvent
|
||||
? () => (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<div className="pb-3 px-2">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="mx-2 overflow-y-auto">
|
||||
<EventDetails event={activeEvent} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
onClick,
|
||||
isActive,
|
||||
event,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
event: HttpResponseEvent;
|
||||
}) {
|
||||
const display = getEventDisplay(event.event);
|
||||
const { icon, color, summary } = display;
|
||||
|
||||
return (
|
||||
<div className="px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
|
||||
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
|
||||
isActive && '!bg-surface-active !text-text',
|
||||
'text-text-subtle hover:text',
|
||||
)}
|
||||
>
|
||||
<Icon color={color} icon={icon} size="sm" />
|
||||
<div className="w-full truncate text-xs">{summary}</div>
|
||||
<div className="text-xs opacity-50">{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function EventDetails({ event }: { event: HttpResponseEvent }) {
|
||||
const { label } = getEventDisplay(event.event);
|
||||
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
|
||||
const e = event.event;
|
||||
|
||||
// Headers - show name and value with Editor for JSON
|
||||
if (e.type === 'header_up' || e.type === 'header_down') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<DetailHeader
|
||||
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Request URL - show method and path separately
|
||||
if (e.type === 'send_url') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Request" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Method">
|
||||
<HttpMethodTagRaw forceColor method={e.method} />
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Response status - show version and status separately
|
||||
if (e.type === 'receive_url') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Response" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect - show status, URL, and behavior
|
||||
if (e.type === 'redirect') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Redirect" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Location">{e.url}</KeyValueRow>
|
||||
<KeyValueRow label="Behavior">
|
||||
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings - show as key/value
|
||||
if (e.type === 'setting') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title="Apply Setting" timestamp={timestamp} />
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chunks - show formatted bytes
|
||||
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
|
||||
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
|
||||
<div className="font-mono text-sm">{formatBytes(e.bytes)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default - use summary
|
||||
const { summary } = getEventDisplay(event.event);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<DetailHeader title={label} timestamp={timestamp} />
|
||||
<div className="font-mono text-sm">{summary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
|
||||
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EventDisplay = {
|
||||
icon: IconProps['icon'];
|
||||
color: IconProps['color'];
|
||||
label: string;
|
||||
summary: ReactNode;
|
||||
};
|
||||
|
||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
||||
switch (event.type) {
|
||||
case 'start_request':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Start',
|
||||
summary: 'Request started',
|
||||
};
|
||||
case 'end_request':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'End',
|
||||
summary: 'Request complete',
|
||||
};
|
||||
case 'setting':
|
||||
return {
|
||||
icon: 'settings',
|
||||
color: 'secondary',
|
||||
label: 'Setting',
|
||||
summary: `${event.name} = ${event.value}`,
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Info',
|
||||
summary: event.message,
|
||||
};
|
||||
case 'redirect':
|
||||
return {
|
||||
icon: 'arrow_big_right_dash',
|
||||
color: 'warning',
|
||||
label: 'Redirect',
|
||||
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
|
||||
};
|
||||
case 'send_url':
|
||||
return {
|
||||
icon: 'arrow_big_up_dash',
|
||||
color: 'primary',
|
||||
label: 'Request',
|
||||
summary: `${event.method} ${event.path}`,
|
||||
};
|
||||
case 'receive_url':
|
||||
return {
|
||||
icon: 'arrow_big_down_dash',
|
||||
color: 'info',
|
||||
label: 'Response',
|
||||
summary: `${event.version} ${event.status}`,
|
||||
};
|
||||
case 'header_up':
|
||||
return {
|
||||
icon: 'arrow_big_up_dash',
|
||||
color: 'primary',
|
||||
label: 'Header',
|
||||
summary: `${event.name}: ${event.value}`,
|
||||
};
|
||||
case 'header_down':
|
||||
return {
|
||||
icon: 'arrow_big_down_dash',
|
||||
color: 'info',
|
||||
label: 'Header',
|
||||
summary: `${event.name}: ${event.value}`,
|
||||
};
|
||||
|
||||
case 'chunk_sent':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Chunk',
|
||||
summary: `${event.bytes} bytes sent`,
|
||||
};
|
||||
case 'chunk_received':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Chunk',
|
||||
summary: `${event.bytes} bytes received`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Unknown',
|
||||
summary: 'Unknown event',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,12 @@ export function HttpMethodTagRaw({
|
||||
className,
|
||||
method,
|
||||
short,
|
||||
forceColor,
|
||||
}: {
|
||||
method: string;
|
||||
className?: string;
|
||||
short?: boolean;
|
||||
forceColor?: boolean;
|
||||
}) {
|
||||
let label = method.toUpperCase();
|
||||
if (short) {
|
||||
@@ -54,7 +56,8 @@ export function HttpMethodTagRaw({
|
||||
|
||||
const m = method.toUpperCase();
|
||||
|
||||
const colored = useAtomValue(settingsAtom).coloredMethods;
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const colored = forceColor || settings.coloredMethods;
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import type { HttpResponseState } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
@@ -8,25 +9,40 @@ interface Props {
|
||||
short?: boolean;
|
||||
}
|
||||
|
||||
export function HttpStatusTag({ response, className, showReason, short }: Props) {
|
||||
const { status, state } = response;
|
||||
export function HttpStatusTag({ response, ...props }: Props) {
|
||||
const { status, state, statusReason } = response;
|
||||
return <HttpStatusTagRaw status={status} state={state} statusReason={statusReason} {...props} />;
|
||||
}
|
||||
|
||||
export function HttpStatusTagRaw({
|
||||
status,
|
||||
state,
|
||||
className,
|
||||
showReason,
|
||||
statusReason,
|
||||
short,
|
||||
}: Omit<Props, 'response'> & {
|
||||
status: number | string;
|
||||
state?: HttpResponseState;
|
||||
statusReason?: string | null;
|
||||
}) {
|
||||
let colorClass: string;
|
||||
let label = `${status}`;
|
||||
const statusN = typeof status === 'number' ? status : parseInt(status, 10);
|
||||
|
||||
if (state === 'initialized') {
|
||||
label = short ? 'CONN' : 'CONNECTING';
|
||||
colorClass = 'text-text-subtle';
|
||||
} else if (status < 100) {
|
||||
} else if (statusN < 100) {
|
||||
label = short ? 'ERR' : 'ERROR';
|
||||
colorClass = 'text-danger';
|
||||
} else if (status < 200) {
|
||||
} else if (statusN < 200) {
|
||||
colorClass = 'text-info';
|
||||
} else if (status < 300) {
|
||||
} else if (statusN < 300) {
|
||||
colorClass = 'text-success';
|
||||
} else if (status < 400) {
|
||||
} else if (statusN < 400) {
|
||||
colorClass = 'text-primary';
|
||||
} else if (status < 500) {
|
||||
} else if (statusN < 500) {
|
||||
colorClass = 'text-warning';
|
||||
} else {
|
||||
colorClass = 'text-danger';
|
||||
@@ -34,7 +50,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
|
||||
|
||||
return (
|
||||
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
|
||||
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
|
||||
{label} {showReason && statusReason}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
28
src-web/hooks/useHttpResponseEvents.ts
Normal file
28
src-web/hooks/useHttpResponseEvents.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||
import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
export function useHttpResponseEvents(response: HttpResponse | null) {
|
||||
const allEvents = useAtomValue(httpResponseEventsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (response?.id == null) {
|
||||
replaceModelsInStore('http_response_event', []);
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<HttpResponseEvent[]>('cmd_get_http_response_events', { responseId: response.id }).then(
|
||||
(events) => replaceModelsInStore('http_response_event', events),
|
||||
);
|
||||
}, [response?.id]);
|
||||
|
||||
// Filter events for the current response
|
||||
const events = useMemo(
|
||||
() => allEvents.filter((e) => e.responseId === response?.id),
|
||||
[allEvents, response?.id],
|
||||
);
|
||||
|
||||
return { data: events, error: null, isLoading: false };
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function languageFromContentType(
|
||||
return 'xml';
|
||||
}
|
||||
if (justContentType.includes('html')) {
|
||||
const detected = detectFromContent(content);
|
||||
const detected = languageFromContent(content);
|
||||
if (detected === 'xml') {
|
||||
// If it's detected as XML, but is already HTML, don't change it
|
||||
return 'html';
|
||||
@@ -22,16 +22,16 @@ export function languageFromContentType(
|
||||
}
|
||||
if (justContentType.includes('javascript')) {
|
||||
// Sometimes `application/javascript` returns JSON, so try detecting that
|
||||
return detectFromContent(content, 'javascript');
|
||||
return languageFromContent(content, 'javascript');
|
||||
}
|
||||
if (justContentType.includes('markdown')) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
return detectFromContent(content, 'text');
|
||||
return languageFromContent(content, 'text');
|
||||
}
|
||||
|
||||
function detectFromContent(
|
||||
export function languageFromContent(
|
||||
content: string | null,
|
||||
fallback?: EditorProps['language'],
|
||||
): EditorProps['language'] {
|
||||
|
||||
@@ -17,6 +17,7 @@ type TauriCmd =
|
||||
| 'cmd_format_json'
|
||||
| 'cmd_get_http_authentication_config'
|
||||
| 'cmd_get_http_authentication_summaries'
|
||||
| 'cmd_get_http_response_events'
|
||||
| 'cmd_get_sse_events'
|
||||
| 'cmd_get_themes'
|
||||
| 'cmd_get_workspace_meta'
|
||||
|
||||
Reference in New Issue
Block a user