Http response events (#326)

This commit is contained in:
Gregory Schier
2025-12-21 14:34:37 -08:00
committed by GitHub
parent 7e0aa919fb
commit 089c7e8dce
18 changed files with 779 additions and 74 deletions

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ out
.tmp .tmp
tmp tmp
.zed .zed
codebook.toml

View File

@@ -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()?;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {},

View File

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

View File

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

View File

@@ -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)),

View File

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

View 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',
};
}
}

View File

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

View File

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

View 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 };
}

View File

@@ -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'] {

View File

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