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
|
||||||
tmp
|
tmp
|
||||||
.zed
|
.zed
|
||||||
|
codebook.toml
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use yaak_http::sender::ReqwestSender;
|
|||||||
use yaak_http::transaction::HttpTransaction;
|
use yaak_http::transaction::HttpTransaction;
|
||||||
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params};
|
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions, append_query_params};
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpResponseState,
|
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
|
||||||
ProxySetting, ProxySettingAuth,
|
HttpResponseState, ProxySetting, ProxySettingAuth,
|
||||||
};
|
};
|
||||||
use yaak_models::query_manager::QueryManagerExt;
|
use yaak_models::query_manager::QueryManagerExt;
|
||||||
use yaak_models::util::UpdateSource;
|
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)?;
|
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
|
// Execute the transaction with cancellation support
|
||||||
// This returns the response with headers, but body is not yet consumed
|
// This returns the response with headers, but body is not yet consumed
|
||||||
let (mut http_response, _events) =
|
// Events (headers, settings, chunks) are sent through the channel
|
||||||
transaction.execute_with_cancellation(sendable_request, cancelled_rx.clone()).await?;
|
let mut http_response = transaction
|
||||||
|
.execute_with_cancellation(sendable_request, cancelled_rx.clone(), event_tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Prepare the response path before consuming the body
|
// Prepare the response path before consuming the body
|
||||||
let dir = app_handle.path().app_data_dir()?;
|
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_mac_window::AppHandleMacWindowExt;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||||
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, Plugin, Workspace,
|
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState,
|
||||||
WorkspaceMeta,
|
Plugin, Workspace, WorkspaceMeta,
|
||||||
};
|
};
|
||||||
use yaak_models::query_manager::QueryManagerExt;
|
use yaak_models::query_manager::QueryManagerExt;
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
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)
|
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]
|
#[tauri::command]
|
||||||
async fn cmd_import_data<R: Runtime>(
|
async fn cmd_import_data<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
@@ -1462,6 +1473,7 @@ pub fn run() {
|
|||||||
cmd_get_http_authentication_summaries,
|
cmd_get_http_authentication_summaries,
|
||||||
cmd_get_http_authentication_config,
|
cmd_get_http_authentication_config,
|
||||||
cmd_get_sse_events,
|
cmd_get_sse_events,
|
||||||
|
cmd_get_http_response_events,
|
||||||
cmd_get_workspace_meta,
|
cmd_get_workspace_meta,
|
||||||
cmd_grpc_go,
|
cmd_grpc_go,
|
||||||
cmd_grpc_reflect,
|
cmd_grpc_reflect,
|
||||||
|
|||||||
@@ -7,20 +7,45 @@ use reqwest::{Client, Method, Version};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
use std::time::Duration;
|
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;
|
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 {
|
pub enum HttpResponseEvent {
|
||||||
Setting(String, String),
|
Setting(String, String),
|
||||||
Info(String),
|
Info(String),
|
||||||
SendUrl { method: String, path: String },
|
Redirect {
|
||||||
ReceiveUrl { version: Version, status: String },
|
url: String,
|
||||||
|
status: u16,
|
||||||
|
behavior: RedirectBehavior,
|
||||||
|
},
|
||||||
|
SendUrl {
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
ReceiveUrl {
|
||||||
|
version: Version,
|
||||||
|
status: String,
|
||||||
|
},
|
||||||
HeaderUp(String, String),
|
HeaderUp(String, String),
|
||||||
HeaderDown(String, String),
|
HeaderDown(String, String),
|
||||||
HeaderUpDone,
|
ChunkSent {
|
||||||
HeaderDownDone,
|
bytes: usize,
|
||||||
|
},
|
||||||
|
ChunkReceived {
|
||||||
|
bytes: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for HttpResponseEvent {
|
impl Display for HttpResponseEvent {
|
||||||
@@ -28,14 +53,47 @@ impl Display for HttpResponseEvent {
|
|||||||
match self {
|
match self {
|
||||||
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
||||||
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
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::SendUrl { method, path } => write!(f, "> {} {}", method, path),
|
||||||
HttpResponseEvent::ReceiveUrl { version, status } => {
|
HttpResponseEvent::ReceiveUrl { version, status } => {
|
||||||
write!(f, "< {} {}", version_to_str(version), status)
|
write!(f, "< {} {}", version_to_str(version), status)
|
||||||
}
|
}
|
||||||
HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value),
|
HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value),
|
||||||
HttpResponseEvent::HeaderUpDone => write!(f, ">"),
|
|
||||||
HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value),
|
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,
|
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 alias for the body stream
|
||||||
type BodyStream = Pin<Box<dyn AsyncRead + Send>>;
|
type BodyStream = Pin<Box<dyn AsyncRead + Send>>;
|
||||||
|
|
||||||
@@ -215,10 +307,11 @@ impl HttpResponse {
|
|||||||
pub trait HttpSender: Send + Sync {
|
pub trait HttpSender: Send + Sync {
|
||||||
/// Send an HTTP request and return the response with headers.
|
/// 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().
|
/// 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(
|
async fn send(
|
||||||
&self,
|
&self,
|
||||||
request: SendableHttpRequest,
|
request: SendableHttpRequest,
|
||||||
events: &mut Vec<HttpResponseEvent>,
|
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse>;
|
) -> Result<HttpResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,8 +338,13 @@ impl HttpSender for ReqwestSender {
|
|||||||
async fn send(
|
async fn send(
|
||||||
&self,
|
&self,
|
||||||
request: SendableHttpRequest,
|
request: SendableHttpRequest,
|
||||||
events: &mut Vec<HttpResponseEvent>,
|
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse> {
|
) -> 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
|
// Parse the HTTP method
|
||||||
let method = Method::from_bytes(request.method.as_bytes())
|
let method = Method::from_bytes(request.method.as_bytes())
|
||||||
.map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?;
|
.map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?;
|
||||||
@@ -282,7 +380,7 @@ impl HttpSender for ReqwestSender {
|
|||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
let sendable_req = req_builder.build()?;
|
let sendable_req = req_builder.build()?;
|
||||||
events.push(HttpResponseEvent::Setting(
|
send_event(HttpResponseEvent::Setting(
|
||||||
"timeout".to_string(),
|
"timeout".to_string(),
|
||||||
if request.options.timeout.unwrap_or_default().is_zero() {
|
if request.options.timeout.unwrap_or_default().is_zero() {
|
||||||
"Infinity".to_string()
|
"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(),
|
path: sendable_req.url().path().to_string(),
|
||||||
method: sendable_req.method().to_string(),
|
method: sendable_req.method().to_string(),
|
||||||
});
|
});
|
||||||
@@ -300,10 +398,9 @@ impl HttpSender for ReqwestSender {
|
|||||||
for (name, value) in sendable_req.headers() {
|
for (name, value) in sendable_req.headers() {
|
||||||
let v = value.to_str().unwrap_or_default().to_string();
|
let v = value.to_str().unwrap_or_default().to_string();
|
||||||
request_headers.insert(name.to_string(), v.clone());
|
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);
|
send_event(HttpResponseEvent::Info("Sending request to server".to_string()));
|
||||||
events.push(HttpResponseEvent::Info("Sending request to server".to_string()));
|
|
||||||
|
|
||||||
// Map some errors to our own, so they look nicer
|
// Map some errors to our own, so they look nicer
|
||||||
let response = self.client.execute(sendable_req).await.map_err(|e| {
|
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 version = Some(version_to_str(&response.version()));
|
||||||
let content_length = response.content_length();
|
let content_length = response.content_length();
|
||||||
|
|
||||||
events.push(HttpResponseEvent::ReceiveUrl {
|
send_event(HttpResponseEvent::ReceiveUrl {
|
||||||
version: response.version(),
|
version: response.version(),
|
||||||
status: response.status().to_string(),
|
status: response.status().to_string(),
|
||||||
});
|
});
|
||||||
@@ -332,11 +429,10 @@ impl HttpSender for ReqwestSender {
|
|||||||
let mut headers = HashMap::new();
|
let mut headers = HashMap::new();
|
||||||
for (key, value) in response.headers() {
|
for (key, value) in response.headers() {
|
||||||
if let Ok(v) = value.to_str() {
|
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());
|
headers.insert(key.to_string(), v.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
events.push(HttpResponseEvent::HeaderDownDone);
|
|
||||||
|
|
||||||
// Determine content encoding for decompression
|
// Determine content encoding for decompression
|
||||||
// HTTP headers are case-insensitive, so we need to search for any casing
|
// 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))),
|
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(
|
Ok(HttpResponse::new(
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender};
|
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};
|
||||||
use crate::types::SendableHttpRequest;
|
use crate::types::SendableHttpRequest;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::watch::Receiver;
|
use tokio::sync::watch::Receiver;
|
||||||
|
|
||||||
/// HTTP Transaction that manages the lifecycle of a request, including redirect handling
|
/// 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.
|
/// Execute the request with cancellation support.
|
||||||
/// Returns an HttpResponse with unconsumed body - caller decides how to consume it.
|
/// 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(
|
pub async fn execute_with_cancellation(
|
||||||
&self,
|
&self,
|
||||||
request: SendableHttpRequest,
|
request: SendableHttpRequest,
|
||||||
mut cancelled_rx: Receiver<bool>,
|
mut cancelled_rx: Receiver<bool>,
|
||||||
) -> Result<(HttpResponse, Vec<HttpResponseEvent>)> {
|
event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
let mut redirect_count = 0;
|
let mut redirect_count = 0;
|
||||||
let mut current_url = request.url;
|
let mut current_url = request.url;
|
||||||
let mut current_method = request.method;
|
let mut current_method = request.method;
|
||||||
let mut current_headers = request.headers;
|
let mut current_headers = request.headers;
|
||||||
let mut current_body = request.body;
|
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 {
|
loop {
|
||||||
// Check for cancellation before each request
|
// Check for cancellation before each request
|
||||||
@@ -50,14 +57,14 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
events.push(HttpResponseEvent::Setting(
|
send_event(HttpResponseEvent::Setting(
|
||||||
"redirects".to_string(),
|
"redirects".to_string(),
|
||||||
request.options.follow_redirects.to_string(),
|
request.options.follow_redirects.to_string(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Execute with cancellation support
|
// Execute with cancellation support
|
||||||
let response = tokio::select! {
|
let response = tokio::select! {
|
||||||
result = self.sender.send(req, &mut events) => result?,
|
result = self.sender.send(req, event_tx.clone()) => result?,
|
||||||
_ = cancelled_rx.changed() => {
|
_ = cancelled_rx.changed() => {
|
||||||
return Err(crate::error::Error::RequestCanceledError);
|
return Err(crate::error::Error::RequestCanceledError);
|
||||||
}
|
}
|
||||||
@@ -65,12 +72,12 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
|
|
||||||
if !Self::is_redirect(response.status) {
|
if !Self::is_redirect(response.status) {
|
||||||
// Not a redirect - return the response for caller to consume body
|
// Not a redirect - return the response for caller to consume body
|
||||||
return Ok((response, events));
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !request.options.follow_redirects {
|
if !request.options.follow_redirects {
|
||||||
// Redirects disabled - return the redirect response as-is
|
// Redirects disabled - return the redirect response as-is
|
||||||
return Ok((response, events));
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've exceeded max redirects
|
// Check if we've exceeded max redirects
|
||||||
@@ -99,7 +106,7 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
// Also get status before draining
|
// Also get status before draining
|
||||||
let status = response.status;
|
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
|
// Drain the redirect response body before following
|
||||||
response.drain().await?;
|
response.drain().await?;
|
||||||
@@ -118,38 +125,36 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
format!("{}/{}", base_path, location)
|
format!("{}/{}", base_path, location)
|
||||||
};
|
};
|
||||||
|
|
||||||
events.push(HttpResponseEvent::Info(format!(
|
// Determine redirect behavior based on status code and method
|
||||||
"Issuing redirect {} to: {}",
|
let behavior = if status == 303 {
|
||||||
redirect_count + 1,
|
// 303 See Other always changes to GET
|
||||||
current_url
|
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
|
// Handle method changes for certain redirect codes
|
||||||
if status == 303 {
|
if matches!(behavior, RedirectBehavior::DropBody) {
|
||||||
// 303 See Other always changes to GET
|
|
||||||
if current_method != "GET" {
|
if current_method != "GET" {
|
||||||
current_method = "GET".to_string();
|
current_method = "GET".to_string();
|
||||||
events.push(HttpResponseEvent::Info("Changing method to GET".to_string()));
|
|
||||||
}
|
}
|
||||||
// Remove content-related headers
|
// Remove content-related headers
|
||||||
current_headers.retain(|h| {
|
current_headers.retain(|h| {
|
||||||
let name_lower = h.0.to_lowercase();
|
let name_lower = h.0.to_lowercase();
|
||||||
!name_lower.starts_with("content-") && name_lower != "transfer-encoding"
|
!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)
|
// 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
|
// For redirects that change method to GET or for all redirects since body was consumed
|
||||||
@@ -231,7 +236,7 @@ mod tests {
|
|||||||
async fn send(
|
async fn send(
|
||||||
&self,
|
&self,
|
||||||
_request: SendableHttpRequest,
|
_request: SendableHttpRequest,
|
||||||
_events: &mut Vec<HttpResponseEvent>,
|
_event_tx: mpsc::UnboundedSender<HttpResponseEvent>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let mut responses = self.responses.lock().await;
|
let mut responses = self.responses.lock().await;
|
||||||
if responses.is_empty() {
|
if responses.is_empty() {
|
||||||
@@ -271,7 +276,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
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);
|
assert_eq!(result.status, 200);
|
||||||
|
|
||||||
// Consume the body to verify it
|
// Consume the body to verify it
|
||||||
@@ -303,7 +309,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
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);
|
assert_eq!(result.status, 200);
|
||||||
|
|
||||||
let (body, _) = result.bytes().await.unwrap();
|
let (body, _) = result.bytes().await.unwrap();
|
||||||
@@ -334,7 +341,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
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 {
|
if let Err(crate::error::Error::RequestError(msg)) = result {
|
||||||
assert!(msg.contains("Maximum redirect limit"));
|
assert!(msg.contains("Maximum redirect limit"));
|
||||||
} else {
|
} 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.
|
// 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, };
|
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 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 HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
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 grpcRequestsAtom = createModelAtom('grpc_request');
|
||||||
export const httpRequestsAtom = createModelAtom('http_request');
|
export const httpRequestsAtom = createModelAtom('http_request');
|
||||||
export const httpResponsesAtom = createOrderedModelAtom('http_response', 'createdAt', 'desc');
|
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 keyValuesAtom = createModelAtom('key_value');
|
||||||
export const pluginsAtom = createModelAtom('plugin');
|
export const pluginsAtom = createModelAtom('plugin');
|
||||||
export const settingsAtom = createSingularModelAtom('settings');
|
export const settingsAtom = createSingularModelAtom('settings');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function newStoreData(): ModelStoreData {
|
|||||||
grpc_request: {},
|
grpc_request: {},
|
||||||
http_request: {},
|
http_request: {},
|
||||||
http_response: {},
|
http_response: {},
|
||||||
|
http_response_event: {},
|
||||||
key_value: {},
|
key_value: {},
|
||||||
plugin: {},
|
plugin: {},
|
||||||
settings: {},
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
@@ -2189,6 +2326,7 @@ define_any_model! {
|
|||||||
GrpcRequest,
|
GrpcRequest,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
|
HttpResponseEvent,
|
||||||
KeyValue,
|
KeyValue,
|
||||||
Plugin,
|
Plugin,
|
||||||
Settings,
|
Settings,
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ impl TryFrom<AnyModel> for SyncModel {
|
|||||||
AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)),
|
AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
|
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::HttpResponse(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::KeyValue(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),
|
AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::Settings(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 { lazy, Suspense, useCallback, useMemo } from 'react';
|
||||||
import { useLocalStorage } from 'react-use';
|
import { useLocalStorage } from 'react-use';
|
||||||
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||||
|
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||||
@@ -23,6 +24,7 @@ import { TabContent, Tabs } from './core/Tabs/Tabs';
|
|||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
||||||
|
import { ResponseEvents } from './ResponseEvents';
|
||||||
import { ResponseHeaders } from './ResponseHeaders';
|
import { ResponseHeaders } from './ResponseHeaders';
|
||||||
import { ResponseInfo } from './ResponseInfo';
|
import { ResponseInfo } from './ResponseInfo';
|
||||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||||
@@ -46,6 +48,7 @@ interface Props {
|
|||||||
const TAB_BODY = 'body';
|
const TAB_BODY = 'body';
|
||||||
const TAB_HEADERS = 'headers';
|
const TAB_HEADERS = 'headers';
|
||||||
const TAB_INFO = 'info';
|
const TAB_INFO = 'info';
|
||||||
|
const TAB_EVENTS = 'events';
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||||
@@ -57,12 +60,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||||
|
|
||||||
|
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
const tabs = useMemo<TabItem[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
value: TAB_BODY,
|
value: TAB_BODY,
|
||||||
label: 'Preview Mode',
|
label: 'Preview Mode',
|
||||||
hidden: (activeResponse?.contentLength || 0) === 0,
|
|
||||||
options: {
|
options: {
|
||||||
value: viewMode,
|
value: viewMode,
|
||||||
onChange: setViewMode,
|
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,
|
value: TAB_INFO,
|
||||||
label: 'Info',
|
label: 'Info',
|
||||||
@@ -93,7 +102,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
setViewMode,
|
setViewMode,
|
||||||
viewMode,
|
viewMode,
|
||||||
activeResponse?.requestHeaders.length,
|
activeResponse?.requestHeaders.length,
|
||||||
activeResponse?.contentLength,
|
responseEvents.data?.length,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const activeTab = activeTabs?.[activeRequestId];
|
const activeTab = activeTabs?.[activeRequestId];
|
||||||
@@ -224,6 +233,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
<TabContent value={TAB_INFO}>
|
<TabContent value={TAB_INFO}>
|
||||||
<ResponseInfo response={activeResponse} />
|
<ResponseInfo response={activeResponse} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
<TabContent value={TAB_EVENTS}>
|
||||||
|
<ResponseEvents response={activeResponse} />
|
||||||
|
</TabContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</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,
|
className,
|
||||||
method,
|
method,
|
||||||
short,
|
short,
|
||||||
|
forceColor,
|
||||||
}: {
|
}: {
|
||||||
method: string;
|
method: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
short?: boolean;
|
short?: boolean;
|
||||||
|
forceColor?: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label = method.toUpperCase();
|
let label = method.toUpperCase();
|
||||||
if (short) {
|
if (short) {
|
||||||
@@ -54,7 +56,8 @@ export function HttpMethodTagRaw({
|
|||||||
|
|
||||||
const m = method.toUpperCase();
|
const m = method.toUpperCase();
|
||||||
|
|
||||||
const colored = useAtomValue(settingsAtom).coloredMethods;
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const colored = forceColor || settings.coloredMethods;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||||
|
import type { HttpResponseState } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,25 +9,40 @@ interface Props {
|
|||||||
short?: boolean;
|
short?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HttpStatusTag({ response, className, showReason, short }: Props) {
|
export function HttpStatusTag({ response, ...props }: Props) {
|
||||||
const { status, state } = response;
|
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 colorClass: string;
|
||||||
let label = `${status}`;
|
let label = `${status}`;
|
||||||
|
const statusN = typeof status === 'number' ? status : parseInt(status, 10);
|
||||||
|
|
||||||
if (state === 'initialized') {
|
if (state === 'initialized') {
|
||||||
label = short ? 'CONN' : 'CONNECTING';
|
label = short ? 'CONN' : 'CONNECTING';
|
||||||
colorClass = 'text-text-subtle';
|
colorClass = 'text-text-subtle';
|
||||||
} else if (status < 100) {
|
} else if (statusN < 100) {
|
||||||
label = short ? 'ERR' : 'ERROR';
|
label = short ? 'ERR' : 'ERROR';
|
||||||
colorClass = 'text-danger';
|
colorClass = 'text-danger';
|
||||||
} else if (status < 200) {
|
} else if (statusN < 200) {
|
||||||
colorClass = 'text-info';
|
colorClass = 'text-info';
|
||||||
} else if (status < 300) {
|
} else if (statusN < 300) {
|
||||||
colorClass = 'text-success';
|
colorClass = 'text-success';
|
||||||
} else if (status < 400) {
|
} else if (statusN < 400) {
|
||||||
colorClass = 'text-primary';
|
colorClass = 'text-primary';
|
||||||
} else if (status < 500) {
|
} else if (statusN < 500) {
|
||||||
colorClass = 'text-warning';
|
colorClass = 'text-warning';
|
||||||
} else {
|
} else {
|
||||||
colorClass = 'text-danger';
|
colorClass = 'text-danger';
|
||||||
@@ -34,7 +50,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
|
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
|
||||||
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
|
{label} {showReason && statusReason}
|
||||||
</span>
|
</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';
|
return 'xml';
|
||||||
}
|
}
|
||||||
if (justContentType.includes('html')) {
|
if (justContentType.includes('html')) {
|
||||||
const detected = detectFromContent(content);
|
const detected = languageFromContent(content);
|
||||||
if (detected === 'xml') {
|
if (detected === 'xml') {
|
||||||
// If it's detected as XML, but is already HTML, don't change it
|
// If it's detected as XML, but is already HTML, don't change it
|
||||||
return 'html';
|
return 'html';
|
||||||
@@ -22,16 +22,16 @@ export function languageFromContentType(
|
|||||||
}
|
}
|
||||||
if (justContentType.includes('javascript')) {
|
if (justContentType.includes('javascript')) {
|
||||||
// Sometimes `application/javascript` returns JSON, so try detecting that
|
// Sometimes `application/javascript` returns JSON, so try detecting that
|
||||||
return detectFromContent(content, 'javascript');
|
return languageFromContent(content, 'javascript');
|
||||||
}
|
}
|
||||||
if (justContentType.includes('markdown')) {
|
if (justContentType.includes('markdown')) {
|
||||||
return 'markdown';
|
return 'markdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
return detectFromContent(content, 'text');
|
return languageFromContent(content, 'text');
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectFromContent(
|
export function languageFromContent(
|
||||||
content: string | null,
|
content: string | null,
|
||||||
fallback?: EditorProps['language'],
|
fallback?: EditorProps['language'],
|
||||||
): EditorProps['language'] {
|
): EditorProps['language'] {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type TauriCmd =
|
|||||||
| 'cmd_format_json'
|
| 'cmd_format_json'
|
||||||
| 'cmd_get_http_authentication_config'
|
| 'cmd_get_http_authentication_config'
|
||||||
| 'cmd_get_http_authentication_summaries'
|
| 'cmd_get_http_authentication_summaries'
|
||||||
|
| 'cmd_get_http_response_events'
|
||||||
| 'cmd_get_sse_events'
|
| 'cmd_get_sse_events'
|
||||||
| 'cmd_get_themes'
|
| 'cmd_get_themes'
|
||||||
| 'cmd_get_workspace_meta'
|
| 'cmd_get_workspace_meta'
|
||||||
|
|||||||
Reference in New Issue
Block a user