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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -5517,6 +5517,11 @@
"type": "string",
"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.",
"type": "string",
@@ -5587,6 +5592,11 @@
"type": "string",
"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.",
"type": "string",

View File

@@ -5517,6 +5517,11 @@
"type": "string",
"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.",
"type": "string",
@@ -5587,6 +5592,11 @@
"type": "string",
"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.",
"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") {
"windows"
} else if cfg!(target_os = "macos") {

View File

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

View File

@@ -1,8 +1,8 @@
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use yaak_http::apply_path_placeholders;
use yaak_models::models::{
Environment, GrpcMetadataEntry, GrpcRequest, HttpRequest,
HttpRequestHeader, HttpUrlParameter,
Environment, GrpcMetadataEntry, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use yaak_models::render::make_vars_hashmap;
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 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_parameters,
headers,
body,
authentication,
..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>(
@@ -119,180 +120,3 @@ pub async fn render<T: TemplateCallback>(
) -> String {
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)
}
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>(
window: &WebviewWindow<R>,
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.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.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

View File

@@ -17,6 +17,7 @@ thiserror = "2.0.11"
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"] }
yaak-models = { workspace = true }
yaak-http = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
serde_json = "1.0.132"

View File

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

View File

@@ -9,6 +9,12 @@ export function upsertWebsocketRequest(
}) as Promise<WebsocketRequest>;
}
export function duplicateWebsocketRequest(requestId: string) {
return invoke('plugin:yaak-ws|duplicate_request', {
requestId,
}) as Promise<WebsocketRequest>;
}
export function deleteWebsocketRequest(requestId: string) {
return invoke('plugin:yaak-ws|delete_request', {
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-connections`
- `allow-delete-request`
- `allow-duplicate-request`
- `allow-list-connections`
- `allow-list-events`
- `allow-list-requests`
@@ -181,6 +182,32 @@ Denies the delete_request command without any pre-configured scope.
<tr>
<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`
</td>

View File

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

View File

@@ -354,6 +354,16 @@
"type": "string",
"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.",
"type": "string",

View File

@@ -6,10 +6,11 @@ use chrono::Utc;
use log::{info, warn};
use std::str::FromStr;
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_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::Message;
use yaak_http::apply_path_placeholders;
use yaak_models::models::{
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
WebsocketEventType, WebsocketRequest,
@@ -33,6 +34,14 @@ pub(crate) async fn upsert_request<R: Runtime>(
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]
pub(crate) async fn delete_request<R: Runtime>(
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,
Err(e) => {
@@ -298,6 +322,7 @@ pub(crate) async fn connect<R: Runtime>(
&window,
&WebsocketConnection {
error: Some(format!("{e:?}")),
state: WebsocketConnectionState::Closed,
..connection
},
&UpdateSource::Window,

View File

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