Url parameters for websocket URLs

This commit is contained in:
Gregory Schier
2025-02-03 11:40:19 -08:00
parent dd0516cc55
commit fcf2577430
27 changed files with 432 additions and 259 deletions

12
src-tauri/Cargo.lock generated
View File

@@ -7619,9 +7619,9 @@ dependencies = [
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"ts-rs", "ts-rs",
"urlencoding",
"uuid", "uuid",
"yaak-grpc", "yaak-grpc",
"yaak-http",
"yaak-license", "yaak-license",
"yaak-models", "yaak-models",
"yaak-plugins", "yaak-plugins",
@@ -7659,6 +7659,15 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "yaak-http"
version = "0.1.0"
dependencies = [
"regex",
"urlencoding",
"yaak-models",
]
[[package]] [[package]]
name = "yaak-license" name = "yaak-license"
version = "0.1.0" version = "0.1.0"
@@ -7774,6 +7783,7 @@ dependencies = [
"thiserror 2.0.11", "thiserror 2.0.11",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"yaak-http",
"yaak-models", "yaak-models",
"yaak-plugins", "yaak-plugins",
"yaak-templates", "yaak-templates",

View File

@@ -1,6 +1,7 @@
[workspace] [workspace]
members = [ members = [
"yaak-grpc", "yaak-grpc",
"yaak-http",
"yaak-license", "yaak-license",
"yaak-models", "yaak-models",
"yaak-plugins", "yaak-plugins",
@@ -70,9 +71,9 @@ tauri-plugin-window-state = "2.2.1"
tokio = { version = "1.43.0", features = ["sync"] } tokio = { version = "1.43.0", features = ["sync"] }
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
ts-rs = { workspace = true } ts-rs = { workspace = true }
urlencoding = "2.1.3"
uuid = "1.12.1" uuid = "1.12.1"
yaak-grpc = { path = "yaak-grpc" } yaak-grpc = { path = "yaak-grpc" }
yaak-http = { workspace = true }
yaak-license = { path = "yaak-license" } yaak-license = { path = "yaak-license" }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-plugins = { workspace = true } yaak-plugins = { workspace = true }
@@ -92,5 +93,6 @@ thiserror = "2.0.3"
ts-rs = "10.0.0" ts-rs = "10.0.0"
yaak-models = { path = "yaak-models" } yaak-models = { path = "yaak-models" }
yaak-plugins = { path = "yaak-plugins" } yaak-plugins = { path = "yaak-plugins" }
yaak-http = { path = "yaak-http" }
yaak-sse = { path = "yaak-sse" } yaak-sse = { path = "yaak-sse" }
yaak-templates = { path = "yaak-templates" } yaak-templates = { path = "yaak-templates" }

File diff suppressed because one or more lines are too long

View File

@@ -5517,6 +5517,11 @@
"type": "string", "type": "string",
"const": "yaak-ws:allow-delete-request" "const": "yaak-ws:allow-delete-request"
}, },
{
"description": "Enables the duplicate_request command without any pre-configured scope.",
"type": "string",
"const": "yaak-ws:allow-duplicate-request"
},
{ {
"description": "Enables the list_connections command without any pre-configured scope.", "description": "Enables the list_connections command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -5587,6 +5592,11 @@
"type": "string", "type": "string",
"const": "yaak-ws:deny-delete-request" "const": "yaak-ws:deny-delete-request"
}, },
{
"description": "Denies the duplicate_request command without any pre-configured scope.",
"type": "string",
"const": "yaak-ws:deny-duplicate-request"
},
{ {
"description": "Denies the list_connections command without any pre-configured scope.", "description": "Denies the list_connections command without any pre-configured scope.",
"type": "string", "type": "string",

View File

@@ -5517,6 +5517,11 @@
"type": "string", "type": "string",
"const": "yaak-ws:allow-delete-request" "const": "yaak-ws:allow-delete-request"
}, },
{
"description": "Enables the duplicate_request command without any pre-configured scope.",
"type": "string",
"const": "yaak-ws:allow-duplicate-request"
},
{ {
"description": "Enables the list_connections command without any pre-configured scope.", "description": "Enables the list_connections command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -5587,6 +5592,11 @@
"type": "string", "type": "string",
"const": "yaak-ws:deny-delete-request" "const": "yaak-ws:deny-delete-request"
}, },
{
"description": "Denies the duplicate_request command without any pre-configured scope.",
"type": "string",
"const": "yaak-ws:deny-duplicate-request"
},
{ {
"description": "Denies the list_connections command without any pre-configured scope.", "description": "Denies the list_connections command without any pre-configured scope.",
"type": "string", "type": "string",

View File

@@ -203,7 +203,7 @@ pub async fn track_event<R: Runtime>(
} }
} }
fn get_os() -> &'static str { pub fn get_os() -> &'static str {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
"windows" "windows"
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {

View File

@@ -1,6 +1,6 @@
use std::time::SystemTime; use std::time::SystemTime;
use crate::analytics::get_num_launches; use crate::analytics::{get_num_launches, get_os};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use log::debug; use log::debug;
use reqwest::Method; use reqwest::Method;
@@ -66,8 +66,9 @@ impl YaakNotifier {
let req = reqwest::Client::default() let req = reqwest::Client::default()
.request(Method::GET, "https://notify.yaak.app/notifications") .request(Method::GET, "https://notify.yaak.app/notifications")
.query(&[ .query(&[
("version", info.version.to_string()), ("version", info.version.to_string().as_str()),
("launches", num_launches.to_string()), ("launches", num_launches.to_string().as_str()),
("platform", get_os())
]); ]);
let resp = req.send().await.map_err(|e| e.to_string())?; let resp = req.send().await.map_err(|e| e.to_string())?;
if resp.status() != 200 { if resp.status() != 200 {

View File

@@ -1,8 +1,8 @@
use serde_json::Value; use serde_json::Value;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use yaak_http::apply_path_placeholders;
use yaak_models::models::{ use yaak_models::models::{
Environment, GrpcMetadataEntry, GrpcRequest, HttpRequest, Environment, GrpcMetadataEntry, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
HttpRequestHeader, HttpUrlParameter,
}; };
use yaak_models::render::make_vars_hashmap; use yaak_models::render::make_vars_hashmap;
use yaak_templates::{parse_and_render, render_json_value_raw, TemplateCallback}; use yaak_templates::{parse_and_render, render_json_value_raw, TemplateCallback};
@@ -99,17 +99,18 @@ pub async fn render_http_request<T: TemplateCallback>(
} }
let url = render(r.url.clone().as_str(), vars, cb).await; let url = render(r.url.clone().as_str(), vars, cb).await;
let req = HttpRequest {
// This doesn't fit perfectly with the concept of "rendering" but it kind of does
let (url, url_parameters) = apply_path_placeholders(&url, url_parameters);
HttpRequest {
url, url,
url_parameters, url_parameters,
headers, headers,
body, body,
authentication, authentication,
..r.to_owned() ..r.to_owned()
}; }
// This doesn't fit perfectly with the concept of "rendering" but it kind of does
apply_path_placeholders(req)
} }
pub async fn render<T: TemplateCallback>( pub async fn render<T: TemplateCallback>(
@@ -119,180 +120,3 @@ pub async fn render<T: TemplateCallback>(
) -> String { ) -> String {
parse_and_render(template, vars, cb).await parse_and_render(template, vars, cb).await
} }
fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
if !p.enabled {
return url.to_string();
}
if !p.name.starts_with(":") {
return url.to_string();
}
let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap();
let result = re
.replace_all(url, |cap: &regex::Captures| {
format!(
"{}{}{}",
cap[1].to_string(),
urlencoding::encode(p.value.as_str()),
cap[2].to_string()
)
})
.into_owned();
result
}
fn apply_path_placeholders(rendered_request: HttpRequest) -> HttpRequest {
let mut url = rendered_request.url.to_owned();
let mut url_parameters = Vec::new();
for p in rendered_request.url_parameters.clone() {
if !p.enabled || p.name.is_empty() {
continue;
}
// Replace path parameters with values from URL parameters
let old_url_string = url.clone();
url = replace_path_placeholder(&p, url.as_str());
// Remove as param if it modified the URL
if old_url_string == url {
url_parameters.push(p);
}
}
let mut request = rendered_request.clone();
request.url_parameters = url_parameters;
request.url = url;
request
}
#[cfg(test)]
mod placeholder_tests {
use crate::render::{apply_path_placeholders, replace_path_placeholder};
use yaak_models::models::{HttpRequest, HttpUrlParameter};
#[test]
fn placeholder_middle() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo/bar"),
"https://example.com/xxx/bar",
);
}
#[test]
fn placeholder_end() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/xxx",
);
}
#[test]
fn placeholder_query() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo?:foo"),
"https://example.com/xxx?:foo",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
enabled: true,
name: "".to_string(),
value: "".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:missing"),
"https://example.com/:missing",
);
}
#[test]
fn placeholder_disabled() {
let p = HttpUrlParameter {
enabled: false,
name: ":foo".to_string(),
value: "xxx".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/:foo",
);
}
#[test]
fn placeholder_prefix() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foooo"),
"https://example.com/:foooo",
);
}
#[test]
fn placeholder_encode() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "Hello World".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/Hello%20World",
);
}
#[test]
fn apply_placeholder() {
let result = apply_path_placeholders(HttpRequest {
url: "example.com/:a/bar".to_string(),
url_parameters: vec![
HttpUrlParameter {
name: "b".to_string(),
value: "bbb".to_string(),
enabled: true,
id: None,
},
HttpUrlParameter {
name: ":a".to_string(),
value: "aaa".to_string(),
enabled: true,
id: None,
},
],
..Default::default()
});
assert_eq!(result.url, "example.com/aaa/bar");
assert_eq!(result.url_parameters.len(), 1);
assert_eq!(result.url_parameters[0].name, "b");
assert_eq!(result.url_parameters[0].value, "bbb");
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "yaak-http"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
yaak-models = { workspace = true }
regex = "1.11.0"
urlencoding = "2.1.3"

View File

@@ -0,0 +1,183 @@
use yaak_models::models::HttpUrlParameter;
pub fn apply_path_placeholders(
url: &str,
parameters: Vec<HttpUrlParameter>,
) -> (String, Vec<HttpUrlParameter>) {
let mut new_parameters = Vec::new();
let mut url = url.to_string();
for p in parameters {
if !p.enabled || p.name.is_empty() {
continue;
}
// Replace path parameters with values from URL parameters
let old_url_string = url.clone();
url = replace_path_placeholder(&p, url.as_str());
// Remove as param if it modified the URL
if old_url_string == *url {
new_parameters.push(p);
}
}
(url, new_parameters)
}
fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
if !p.enabled {
return url.to_string();
}
if !p.name.starts_with(":") {
return url.to_string();
}
let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap();
let result = re
.replace_all(url, |cap: &regex::Captures| {
format!(
"{}{}{}",
cap[1].to_string(),
urlencoding::encode(p.value.as_str()),
cap[2].to_string()
)
})
.into_owned();
result
}
#[cfg(test)]
mod placeholder_tests {
use crate::{apply_path_placeholders, replace_path_placeholder};
use yaak_models::models::{HttpRequest, HttpUrlParameter};
#[test]
fn placeholder_middle() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo/bar"),
"https://example.com/xxx/bar",
);
}
#[test]
fn placeholder_end() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/xxx",
);
}
#[test]
fn placeholder_query() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo?:foo"),
"https://example.com/xxx?:foo",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
enabled: true,
name: "".to_string(),
value: "".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:missing"),
"https://example.com/:missing",
);
}
#[test]
fn placeholder_disabled() {
let p = HttpUrlParameter {
enabled: false,
name: ":foo".to_string(),
value: "xxx".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/:foo",
);
}
#[test]
fn placeholder_prefix() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foooo"),
"https://example.com/:foooo",
);
}
#[test]
fn placeholder_encode() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "Hello World".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/Hello%20World",
);
}
#[test]
fn apply_placeholder() {
let req = HttpRequest {
url: "example.com/:a/bar".to_string(),
url_parameters: vec![
HttpUrlParameter {
name: "b".to_string(),
value: "bbb".to_string(),
enabled: true,
id: None,
},
HttpUrlParameter {
name: ":a".to_string(),
value: "aaa".to_string(),
enabled: true,
id: None,
},
],
..Default::default()
};
let (url, url_parameters) = apply_path_placeholders(&req.url, req.url_parameters);
// Pattern match back to access it
assert_eq!(url, "example.com/aaa/bar");
assert_eq!(url_parameters.len(), 1);
assert_eq!(url_parameters[0].name, "b");
assert_eq!(url_parameters[0].value, "bbb");
}
}

View File

@@ -1054,6 +1054,20 @@ pub async fn upsert_websocket_event<R: Runtime>(
Ok(m) Ok(m)
} }
pub async fn duplicate_websocket_request<R: Runtime>(
window: &WebviewWindow<R>,
id: &str,
update_source: &UpdateSource,
) -> Result<WebsocketRequest> {
let mut request = match get_websocket_request(window, id).await? {
None => return Err(ModelNotFound(id.to_string())),
Some(r) => r,
};
request.id = "".to_string();
request.sort_priority = request.sort_priority + 0.001;
upsert_websocket_request(window, request, update_source).await
}
pub async fn upsert_websocket_request<R: Runtime>( pub async fn upsert_websocket_request<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
request: WebsocketRequest, request: WebsocketRequest,
@@ -2653,7 +2667,9 @@ pub async fn get_workspace_export_resources<R: Runtime>(
data.resources.folders.append(&mut list_folders(mgr, workspace_id).await?); data.resources.folders.append(&mut list_folders(mgr, workspace_id).await?);
data.resources.http_requests.append(&mut list_http_requests(mgr, workspace_id).await?); data.resources.http_requests.append(&mut list_http_requests(mgr, workspace_id).await?);
data.resources.grpc_requests.append(&mut list_grpc_requests(mgr, workspace_id).await?); data.resources.grpc_requests.append(&mut list_grpc_requests(mgr, workspace_id).await?);
data.resources.websocket_requests.append(&mut list_websocket_requests(mgr, workspace_id).await?); data.resources
.websocket_requests
.append(&mut list_websocket_requests(mgr, workspace_id).await?);
} }
// Nuke environments if we don't want them // Nuke environments if we don't want them

View File

@@ -17,6 +17,7 @@ thiserror = "2.0.11"
tokio = { version = "1.0", default-features = false, features = ["macros", "time", "test-util"] } tokio = { version = "1.0", default-features = false, features = ["macros", "time", "test-util"] }
tokio-tungstenite = { version = "0.26.1", default-features = false, features = ["rustls-tls-native-roots", "connect"] } tokio-tungstenite = { version = "0.26.1", default-features = false, features = ["rustls-tls-native-roots", "connect"] }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-http = { workspace = true }
yaak-plugins = { workspace = true } yaak-plugins = { workspace = true }
yaak-templates = { workspace = true } yaak-templates = { workspace = true }
serde_json = "1.0.132" serde_json = "1.0.132"

View File

@@ -1,10 +1,11 @@
use tauri_plugin; use tauri_plugin;
const COMMANDS: &[&str] = &[ const COMMANDS: &[&str] = &[
"close",
"connect", "connect",
"close",
"delete_connection", "delete_connection",
"delete_connections", "delete_connections",
"delete_request", "delete_request",
"duplicate_request",
"list_connections", "list_connections",
"list_events", "list_events",
"list_requests", "list_requests",

View File

@@ -9,6 +9,12 @@ export function upsertWebsocketRequest(
}) as Promise<WebsocketRequest>; }) as Promise<WebsocketRequest>;
} }
export function duplicateWebsocketRequest(requestId: string) {
return invoke('plugin:yaak-ws|duplicate_request', {
requestId,
}) as Promise<WebsocketRequest>;
}
export function deleteWebsocketRequest(requestId: string) { export function deleteWebsocketRequest(requestId: string) {
return invoke('plugin:yaak-ws|delete_request', { return invoke('plugin:yaak-ws|delete_request', {
requestId, requestId,

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-duplicate-request"
description = "Enables the duplicate_request command without any pre-configured scope."
commands.allow = ["duplicate_request"]
[[permission]]
identifier = "deny-duplicate-request"
description = "Denies the duplicate_request command without any pre-configured scope."
commands.deny = ["duplicate_request"]

View File

@@ -7,6 +7,7 @@ Default permissions for the plugin
- `allow-delete-connection` - `allow-delete-connection`
- `allow-delete-connections` - `allow-delete-connections`
- `allow-delete-request` - `allow-delete-request`
- `allow-duplicate-request`
- `allow-list-connections` - `allow-list-connections`
- `allow-list-events` - `allow-list-events`
- `allow-list-requests` - `allow-list-requests`
@@ -181,6 +182,32 @@ Denies the delete_request command without any pre-configured scope.
<tr> <tr>
<td> <td>
`yaak-ws:allow-duplicate-request`
</td>
<td>
Enables the duplicate_request command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-ws:deny-duplicate-request`
</td>
<td>
Denies the duplicate_request command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-ws:allow-list-connections` `yaak-ws:allow-list-connections`
</td> </td>

View File

@@ -6,6 +6,7 @@ permissions = [
"allow-delete-connection", "allow-delete-connection",
"allow-delete-connections", "allow-delete-connections",
"allow-delete-request", "allow-delete-request",
"allow-duplicate-request",
"allow-list-connections", "allow-list-connections",
"allow-list-events", "allow-list-events",
"allow-list-requests", "allow-list-requests",

View File

@@ -354,6 +354,16 @@
"type": "string", "type": "string",
"const": "deny-delete-request" "const": "deny-delete-request"
}, },
{
"description": "Enables the duplicate_request command without any pre-configured scope.",
"type": "string",
"const": "allow-duplicate-request"
},
{
"description": "Denies the duplicate_request command without any pre-configured scope.",
"type": "string",
"const": "deny-duplicate-request"
},
{ {
"description": "Enables the list_connections command without any pre-configured scope.", "description": "Enables the list_connections command without any pre-configured scope.",
"type": "string", "type": "string",

View File

@@ -6,10 +6,11 @@ use chrono::Utc;
use log::{info, warn}; use log::{info, warn};
use std::str::FromStr; use std::str::FromStr;
use tauri::http::{HeaderMap, HeaderName}; use tauri::http::{HeaderMap, HeaderName};
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow}; use tauri::{AppHandle, Manager, Runtime, State, Url, WebviewWindow};
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use yaak_http::apply_path_placeholders;
use yaak_models::models::{ use yaak_models::models::{
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent, HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
WebsocketEventType, WebsocketRequest, WebsocketEventType, WebsocketRequest,
@@ -33,6 +34,14 @@ pub(crate) async fn upsert_request<R: Runtime>(
Ok(queries::upsert_websocket_request(&w, request, &UpdateSource::Window).await?) Ok(queries::upsert_websocket_request(&w, request, &UpdateSource::Window).await?)
} }
#[tauri::command]
pub(crate) async fn duplicate_request<R: Runtime>(
request_id: &str,
w: WebviewWindow<R>,
) -> Result<WebsocketRequest> {
Ok(queries::duplicate_websocket_request(&w, request_id, &UpdateSource::Window).await?)
}
#[tauri::command] #[tauri::command]
pub(crate) async fn delete_request<R: Runtime>( pub(crate) async fn delete_request<R: Runtime>(
request_id: &str, request_id: &str,
@@ -290,7 +299,22 @@ pub(crate) async fn connect<R: Runtime>(
}); });
} }
let response = match ws_manager.connect(&connection.id, &request.url, headers, receive_tx).await
let (url, url_parameters) = apply_path_placeholders(&request.url, request.url_parameters);
// Add URL parameters to URL
let mut url = Url::parse(&url).unwrap();
{
let mut query_pairs = url.query_pairs_mut();
for p in url_parameters.clone() {
if !p.enabled || p.name.is_empty() {
continue;
}
query_pairs.append_pair(p.name.as_str(), p.value.as_str());
}
}
let response = match ws_manager.connect(&connection.id, url.as_str(), headers, receive_tx).await
{ {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
@@ -298,6 +322,7 @@ pub(crate) async fn connect<R: Runtime>(
&window, &window,
&WebsocketConnection { &WebsocketConnection {
error: Some(format!("{e:?}")), error: Some(format!("{e:?}")),
state: WebsocketConnectionState::Closed,
..connection ..connection
}, },
&UpdateSource::Window, &UpdateSource::Window,

View File

@@ -5,8 +5,8 @@ mod manager;
mod render; mod render;
use crate::cmd::{ use crate::cmd::{
close, connect, delete_connection, delete_connections, delete_request, list_connections, connect, close, delete_connection, delete_connections, delete_request, duplicate_request,
list_events, list_requests, send, upsert_request, list_connections, list_events, list_requests, send, upsert_request,
}; };
use crate::manager::WebsocketManager; use crate::manager::WebsocketManager;
use tauri::plugin::{Builder, TauriPlugin}; use tauri::plugin::{Builder, TauriPlugin};
@@ -16,11 +16,12 @@ use tokio::sync::Mutex;
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-ws") Builder::new("yaak-ws")
.invoke_handler(generate_handler![ .invoke_handler(generate_handler![
close,
connect, connect,
close,
delete_connection, delete_connection,
delete_connections, delete_connections,
delete_request, delete_request,
duplicate_request,
list_connections, list_connections,
list_events, list_events,
list_requests, list_requests,

View File

@@ -0,0 +1,20 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import { duplicateWebsocketRequest as cmdDuplicateWebsocketRequest } from '@yaakapp-internal/ws';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { router } from '../lib/router';
export const duplicateWebsocketRequest = createFastMutation({
mutationKey: ['delete_websocket_connection'],
mutationFn: async function (request: WebsocketRequest) {
return cmdDuplicateWebsocketRequest(request.id);
},
onSuccess: async (request) => {
trackEvent('websocket_request', 'duplicate');
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: request.workspaceId },
search: (prev) => ({ ...prev, request_id: request.id }),
});
},
});

View File

@@ -81,13 +81,15 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
/> />
</div> </div>
</HStack> </HStack>
{activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)}
<AutoScroller <AutoScroller
data={events} data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => ( render={(event) => (
<EventRow <EventRow
key={event.id} key={event.id}

View File

@@ -85,13 +85,15 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
/> />
</HStack> </HStack>
</HStack> </HStack>
{activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)}
<AutoScroller <AutoScroller
data={events} data={events}
header={
activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)
}
render={(event) => ( render={(event) => (
<EventRow <EventRow
key={event.id} key={event.id}

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import {duplicateWebsocketRequest} from "../commands/duplicateWebsocketRequest";
import { import {
useEnsureActiveCookieJar, useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId, useSubscribeActiveCookieJarId,
@@ -253,10 +254,17 @@ function useGlobalWorkspaceHooks() {
useHotKey('http_request.duplicate', async () => { useHotKey('http_request.duplicate', async () => {
const activeRequest = getActiveRequest(); const activeRequest = getActiveRequest();
if (activeRequest?.model === 'http_request') { if (activeRequest == null) {
// Nothing
} else if (activeRequest.model === 'http_request') {
await duplicateHttpRequest.mutateAsync(); await duplicateHttpRequest.mutateAsync();
} else { } else if (activeRequest.model === 'grpc_request') {
await duplicateGrpcRequest.mutateAsync(); await duplicateGrpcRequest.mutateAsync();
} else if (activeRequest.model === 'websocket_request') {
await duplicateWebsocketRequest.mutateAsync(activeRequest);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
throw new Error('Failed to duplicate invalid request model: ' + (activeRequest as any).model);
} }
}); });
} }

View File

@@ -1,14 +1,15 @@
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import type { ReactElement, UIEvent } from 'react'; import type { ReactElement, ReactNode, UIEvent } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
interface Props<T> { interface Props<T> {
data: T[]; data: T[];
render: (item: T, index: number) => ReactElement<HTMLElement>; render: (item: T, index: number) => ReactElement<HTMLElement>;
header?: ReactNode;
} }
export function AutoScroller<T>({ data, render }: Props<T>) { export function AutoScroller<T>({ data, render, header }: Props<T>) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(true); const [autoScroll, setAutoScroll] = useState<boolean>(true);
@@ -45,7 +46,7 @@ export function AutoScroller<T>({ data, render }: Props<T>) {
}, [autoScroll, data.length]); }, [autoScroll, data.length]);
return ( return (
<div className="h-full w-full relative"> <div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
{!autoScroll && ( {!autoScroll && (
<div className="absolute bottom-0 right-0 m-2"> <div className="absolute bottom-0 right-0 m-2">
<IconButton <IconButton
@@ -59,6 +60,7 @@ export function AutoScroller<T>({ data, render }: Props<T>) {
/> />
</div> </div>
)} )}
{header ?? <span aria-hidden/>}
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}> <div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
<div <div
style={{ style={{
@@ -75,7 +77,7 @@ export function AutoScroller<T>({ data, render }: Props<T>) {
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
// height: `${virtualItem.size}px`, height: `${virtualItem.size * 1000}px`,
transform: `translateY(${virtualItem.start}px)`, transform: `translateY(${virtualItem.start}px)`,
}} }}
> >

View File

@@ -9,19 +9,17 @@ interface Props {
export function Banner({ children, className, color }: Props) { export function Banner({ children, className, color }: Props) {
return ( return (
<div> <div
<div className={classNames(
className={classNames( className,
className, `x-theme-banner--${color}`,
`x-theme-banner--${color}`, 'whitespace-pre-wrap',
'whitespace-pre-wrap', 'border border-dashed border-border bg-surface',
'border border-dashed border-border bg-surface', 'px-3 py-2 rounded select-auto',
'px-3 py-2 rounded select-auto', 'overflow-auto text-text',
'overflow-x-auto text-text', )}
)} >
> {children}
{children}
</div>
</div> </div>
); );
} }

View File

@@ -6,6 +6,7 @@ import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource'; import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType'; import { isJSON } from '../../lib/contentType';
import { AutoScroller } from '../core/AutoScroller'; import { AutoScroller } from '../core/AutoScroller';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor'; import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/Editor'; import { Editor } from '../core/Editor/Editor';
@@ -51,10 +52,26 @@ function ActualEventStreamViewer({ response }: Props) {
defaultRatio={0.4} defaultRatio={0.4}
minHeightPx={20} minHeightPx={20}
firstSlot={() => ( firstSlot={() => (
<EventStreamEvents <AutoScroller
events={events.data ?? []} data={events.data ?? []}
activeEventIndex={activeEventIndex} header={
setActiveEventIndex={setActiveEventIndex} events.error && (
<Banner color="danger" className="m-3">
{String(events.error)}
</Banner>
)
}
render={(event, i) => (
<EventRow
event={event}
isActive={i === activeEventIndex}
index={i}
onClick={() => {
if (i === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(i);
}}
/>
)}
/> />
)} )}
secondSlot={ secondSlot={
@@ -112,34 +129,7 @@ function FormattedEditor({ text, language }: { text: string; language: EditorPro
return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />; return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />;
} }
function EventStreamEvents({ function EventRow({
events,
activeEventIndex,
setActiveEventIndex,
}: {
events: ServerSentEvent[];
activeEventIndex: number | null;
setActiveEventIndex: (eventId: number | null) => void;
}) {
return (
<AutoScroller
data={events}
render={(event, i) => (
<EventStreamEvent
event={event}
isActive={i === activeEventIndex}
index={i}
onClick={() => {
if (i === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(i);
}}
/>
)}
/>
);
}
function EventStreamEvent({
onClick, onClick,
isActive, isActive,
event, event,