Add the browser send proxy and web sender (#572)

This commit is contained in:
Gregory Schier
2026-08-18 08:28:01 -07:00
committed by GitHub
parent 4b2dcf9a1a
commit 69083918f9
53 changed files with 2731 additions and 378 deletions
+11
View File
@@ -70,6 +70,17 @@ export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
-1
View File
@@ -19,7 +19,6 @@ hyper-util = { version = "0.1.17", default-features = false, features = ["client
log = { workspace = true }
mime_guess = "2.0.5"
native-tls = { version = "0.2", features = ["alpn"] }
regex = "1.11.1"
reqwest = { workspace = true, features = [
"rustls-tls-manual-roots-no-provider",
"native-tls",
+11 -3
View File
@@ -1,4 +1,4 @@
use crate::dns::LocalhostResolver;
use crate::dns::{AddressFilter, LocalhostResolver};
use crate::error::Result;
use log::{debug, info, warn};
use reqwest::{Client, ClientBuilder, Proxy, redirect};
@@ -103,13 +103,18 @@ pub struct HttpConnectionOptions {
pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
/// Refuse connections to addresses a hostname resolves to. `None` means
/// every resolved address is connectable, which is what the desktop wants:
/// a user sending to their own machine or their own network is the point.
/// A hosted sender is the caller that supplies one.
pub address_filter: Option<AddressFilter>,
}
impl HttpConnectionOptions {
/// Build a reqwest Client and return it along with the DNS resolver.
/// The resolver is returned separately so it can be configured per-request
/// to emit DNS timing events to the appropriate channel.
pub(crate) fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
pub fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
let mut client = client_builder()
.connection_verbose(true)
.redirect(redirect::Policy::none())
@@ -135,7 +140,10 @@ impl HttpConnectionOptions {
}
// Configure DNS resolver - keep a reference to configure per-request
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
let resolver = LocalhostResolver::with_address_filter(
self.dns_overrides.clone(),
self.address_filter.clone(),
);
client = client.dns_resolver(resolver.clone());
// Configure proxy
+39
View File
@@ -20,15 +20,32 @@ pub struct ResolvedOverride {
pub ipv6: Vec<Ipv6Addr>,
}
/// A veto on the addresses a hostname resolves to, consulted after resolution
/// and before any connection is made. Returning `Err` refuses the whole lookup
/// with that message; a hostname is never partially allowed.
///
/// A hosted sender uses this to refuse private and metadata ranges no matter
/// what name they hide behind. Checking here rather than on the URL is what
/// catches a public hostname that resolves to an internal address.
pub type AddressFilter = Arc<dyn Fn(IpAddr) -> std::result::Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct LocalhostResolver {
fallback: HyperGaiResolver,
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
overrides: Arc<HashMap<String, ResolvedOverride>>,
address_filter: Option<AddressFilter>,
}
impl LocalhostResolver {
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
Self::with_address_filter(dns_overrides, None)
}
pub fn with_address_filter(
dns_overrides: Vec<DnsOverride>,
address_filter: Option<AddressFilter>,
) -> Arc<Self> {
let resolver = HyperGaiResolver::new();
// Pre-parse DNS overrides into a lookup map
@@ -55,9 +72,25 @@ impl LocalhostResolver {
fallback: resolver,
event_tx: Arc::new(RwLock::new(None)),
overrides: Arc::new(overrides),
address_filter,
})
}
/// Apply the address filter, if any, to a resolved address list.
fn filter_addrs(
filter: &Option<AddressFilter>,
addrs: &[SocketAddr],
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(filter) = filter {
for addr in addrs {
if let Err(reason) = filter(addr.ip()) {
return Err(Box::new(std::io::Error::other(reason)));
}
}
}
Ok(())
}
/// Set the event sender for the current request.
/// This should be called before each request to direct DNS events
/// to the appropriate channel.
@@ -72,6 +105,7 @@ impl Resolve for LocalhostResolver {
let host = name.as_str().to_lowercase();
let event_tx = self.event_tx.clone();
let overrides = self.overrides.clone();
let address_filter = self.address_filter.clone();
info!("DNS resolve called for: {}", host);
@@ -94,6 +128,8 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for override
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -125,6 +161,8 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for localhost resolution
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -161,6 +199,7 @@ impl Resolve for LocalhostResolver {
Ok(addrs) => {
// Collect addresses for event emission
let addr_vec: Vec<SocketAddr> = addrs.collect();
Self::filter_addrs(&address_filter, &addr_vec)?;
let addresses: Vec<String> =
addr_vec.iter().map(|a| a.ip().to_string()).collect();
+4 -1
View File
@@ -5,9 +5,12 @@ pub mod decompress;
pub mod dns;
pub mod error;
pub mod manager;
pub mod path_placeholders;
mod proto;
pub mod sender;
pub mod tee_reader;
pub mod transaction;
pub mod types;
// Moved to yaak-models so the browser's wasm host can render requests with the
// same code; re-exported here so existing callers keep their path.
pub use yaak_models::path_placeholders;
+2
View File
@@ -19,8 +19,10 @@ serde_json = { workspace = true }
schemars = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
urlencoding = "2.1.3"
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-core = { workspace = true }
yaak-templates = { path = "../yaak-templates", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
+16
View File
@@ -304,6 +304,22 @@ export type HttpResponseHeader = { name: string; value: string };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = {
validateCertificates: boolean;
followRedirects: boolean;
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number;
sendCookies: boolean;
storeCookies: boolean;
};
export type HttpUrlParameter = {
enabled?: boolean;
/**
+107
View File
@@ -0,0 +1,107 @@
//! Carrying a send's cookie changes back into a jar.
//!
//! A send starts from a snapshot of the jar and hands back the jar as the
//! transaction left it. Writing that whole result over the jar would also
//! write over anything the user changed *while* the send was in flight — a
//! cookie edited or deleted in the jar view, or set by another send. So the
//! send's contribution is taken as a difference (what it added, changed, or
//! removed relative to its snapshot) and applied to whatever the jar holds now.
use crate::models::{Cookie, CookieDomain};
/// The identity of a cookie in a jar: two cookies with the same name, domain
/// and path are the same cookie, whatever their value or attributes.
type CookieKey = (String, CookieDomain, String);
fn key(c: &Cookie) -> CookieKey {
(c.name.clone(), c.domain.clone(), c.path.clone())
}
/// Apply the changes between `before` (the snapshot a send started from) and
/// `after` (the jar as the send left it) to `current` (the jar as it is now).
///
/// Cookies the send removed are removed; cookies it added or changed replace
/// their counterpart in `current`, or are appended. Cookies the send did not
/// touch are left exactly as `current` has them.
pub fn apply_cookie_changes(
current: Vec<Cookie>,
before: &[Cookie],
after: &[Cookie],
) -> Vec<Cookie> {
let removed: Vec<CookieKey> =
before.iter().filter(|b| !after.iter().any(|a| key(a) == key(b))).map(key).collect();
let changed: Vec<&Cookie> = after.iter().filter(|a| !before.iter().any(|b| b == *a)).collect();
let mut result: Vec<Cookie> =
current.into_iter().filter(|c| !removed.contains(&key(c))).collect();
for cookie in changed {
match result.iter_mut().find(|c| key(c) == key(cookie)) {
Some(existing) => *existing = cookie.clone(),
None => result.push(cookie.clone()),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::CookieExpires;
fn cookie(name: &str, value: &str) -> Cookie {
Cookie {
name: name.to_string(),
value: value.to_string(),
domain: CookieDomain::HostOnly("example.com".to_string()),
expires: CookieExpires::SessionEnd,
path: "/".to_string(),
secure: false,
http_only: false,
same_site: None,
}
}
#[test]
fn a_send_that_changed_nothing_leaves_the_jar_alone() {
let before = vec![cookie("a", "1")];
let current = vec![cookie("a", "edited"), cookie("b", "2")];
assert_eq!(apply_cookie_changes(current.clone(), &before, &before), current);
}
#[test]
fn additions_and_changes_land_without_touching_concurrent_edits() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("a", "1"), cookie("b", "3"), cookie("c", "4")];
// Meanwhile the user edited `a` and added `d`.
let current = vec![cookie("a", "edited"), cookie("b", "2"), cookie("d", "5")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![
cookie("a", "edited"),
cookie("b", "3"),
cookie("d", "5"),
cookie("c", "4")
]
);
}
#[test]
fn a_cookie_the_send_removed_is_removed() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("b", "2")];
let current = vec![cookie("a", "1"), cookie("b", "2"), cookie("c", "3")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![cookie("b", "2"), cookie("c", "3")]
);
}
#[test]
fn a_cookie_the_user_deleted_mid_send_stays_deleted_unless_the_send_set_it() {
let before = vec![cookie("a", "1")];
let after = vec![cookie("a", "1")]; // untouched by the send
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![]);
let after = vec![cookie("a", "fresh")]; // the send set it again
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![cookie("a", "fresh")]);
}
}
+2
View File
@@ -10,11 +10,13 @@ use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
pub mod cookies;
mod connection_or_tx;
pub mod error;
pub mod migrate;
pub mod models;
pub mod models_ops;
pub mod path_placeholders;
pub mod queries;
pub mod query_manager;
pub mod render;
+64
View File
@@ -158,6 +158,70 @@ impl Default for ResolvedHttpRequestSettings {
}
}
impl ResolvedHttpRequestSettings {
/// The `* Setting name=value` lines a send writes at the top of its timeline, sources and
/// all. Built here, once, so every host that runs a send — the desktop, the CLI, the browser
/// tab handing off to a proxy — records the same lines the same way.
pub fn timeline_events(&self) -> Vec<HttpResponseEventData> {
fn event<T>(
name: &str,
value: String,
setting: &ResolvedSetting<T>,
) -> HttpResponseEventData {
HttpResponseEventData::Setting {
name: name.to_string(),
value,
source_model: Some(setting.source_model.clone()),
source_id: setting.source_id.clone(),
source_name: setting.source_name.clone(),
}
}
let timeout = if self.request_timeout.value > 0 {
format!("{:?}", std::time::Duration::from_millis(self.request_timeout.value as u64))
} else {
"Infinity".to_string()
};
vec![
event(
"validate_certificates",
self.validate_certificates.value.to_string(),
&self.validate_certificates,
),
event("redirects", self.follow_redirects.value.to_string(), &self.follow_redirects),
event("timeout", timeout, &self.request_timeout),
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
]
}
}
/// The resolved send settings, values only: what an executor has to obey, with the sources
/// (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
/// crosses from a tab to the send proxy, and what the proxy reads.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpSendSettings {
pub validate_certificates: bool,
pub follow_redirects: bool,
/// Milliseconds. Zero or negative means no timeout.
pub timeout_ms: i32,
pub send_cookies: bool,
pub store_cookies: bool,
}
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
fn from(s: &ResolvedHttpRequestSettings) -> Self {
Self {
validate_certificates: s.validate_certificates.value,
follow_redirects: s.follow_redirects.value,
timeout_ms: s.request_timeout.value,
send_cookies: s.send_cookies.value,
store_cookies: s.store_cookies.value,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
@@ -1,4 +1,4 @@
use yaak_models::models::HttpUrlParameter;
use crate::models::HttpUrlParameter;
pub fn apply_path_placeholders(
url: &str,
@@ -34,27 +34,41 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
return url.to_string();
}
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
// placeholder while leaving `:increment-importance` as literal text.
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();
// A placeholder is `/` followed by the parameter's name (which starts with `:`), and it
// ends at `/`, `?`, `#`, a literal `:`, or the end of the URL. The `:` boundary is what
// lets `/:id:increment-importance` substitute the `:id` placeholder while leaving
// `:increment-importance` as literal text. `/:foooo` is not a match for `:foo`.
//
// A plain scan rather than a regex: the name is matched literally, so a name containing
// `.` or `+` means exactly that, and nothing else in the model layer needs a regex engine.
let name = p.name.as_str();
let value = urlencoding::encode(p.value.as_str());
let mut result = String::with_capacity(url.len());
let mut rest = url;
while let Some(slash) = rest.find('/') {
let after_slash = &rest[slash + 1..];
let is_placeholder = after_slash.starts_with(name)
&& after_slash[name.len()..]
.chars()
.next()
.is_none_or(|c| matches!(c, '/' | '?' | '#' | ':'));
if is_placeholder {
result.push_str(&rest[..=slash]);
result.push_str(&value);
rest = &after_slash[name.len()..];
} else {
result.push_str(&rest[..=slash]);
rest = after_slash;
}
}
result.push_str(rest);
result
}
#[cfg(test)]
mod placeholder_tests {
use crate::models::{HttpRequest, HttpUrlParameter};
use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};
use yaak_models::models::{HttpRequest, HttpUrlParameter};
#[test]
fn placeholder_middle() {
@@ -98,6 +112,30 @@ mod placeholder_tests {
);
}
#[test]
fn placeholder_name_is_matched_literally() {
// `.` in a name is a dot, not "any character".
let p = HttpUrlParameter {
name: ":id.v2".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id.v2/:idXv2"),
"https://example.com/xxx/:idXv2",
);
}
#[test]
fn placeholder_repeated() {
let p = HttpUrlParameter { name: ":id".into(), value: "7".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id/:id"),
"https://example.com/7/7",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
+223 -2
View File
@@ -1,5 +1,159 @@
use crate::models::{Environment, EnvironmentVariable};
use std::collections::HashMap;
//! Rendering requests against an environment chain.
//!
//! Lives here rather than beside the send engine so that the browser's wasm
//! host, which has the model layer but no sockets, renders exactly what the
//! desktop renders.
use crate::models::{
Environment, EnvironmentVariable, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use crate::path_placeholders::apply_path_placeholders;
use log::info;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
/// Render every template in an HTTP request against an environment chain.
pub async fn render_http_request<T: TemplateCallback>(
request: &HttpRequest,
environment_chain: Vec<Environment>,
callback: &T,
options: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for parameter in request.url_parameters.clone() {
if !parameter.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: parameter.enabled,
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
id: parameter.id,
})
}
let mut headers = Vec::new();
for header in request.headers.clone() {
if !header.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: header.enabled,
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
id: header.id,
})
}
let mut body = BTreeMap::new();
for (key, value) in request.body.clone() {
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match request.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(template)) => {
disabled = parse_and_render(template.as_str(), vars, callback, options)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (key, value) in request.authentication.clone() {
if key == "disabled" {
auth.insert(key, Value::Bool(false));
} else {
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
}
}
auth
};
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
pub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {
let mut variables = HashMap::new();
@@ -27,3 +181,70 @@ fn add_variable_to_map(
map
}
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}
+7
View File
@@ -10,6 +10,13 @@ wasm-opt = false # Causes errors in CI (haven't figured out why yet)
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["wasm"]
# The `#[wasm_bindgen]` exports (parse_template etc.) that make up the
# @yaakapp-internal/templates package. Off for crates that link this one into
# their own wasm module and do not want these re-exported from theirs.
wasm = []
[dependencies]
base64 = "0.22.1"
serde = { workspace = true, features = ["derive"] }
+1
View File
@@ -4,6 +4,7 @@ pub mod format_json;
pub mod parser;
pub mod renderer;
pub mod strip_json_comments;
#[cfg(feature = "wasm")]
pub mod wasm;
pub use parser::*;
+2
View File
@@ -27,6 +27,8 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
# No default features: the template exports belong to @yaakapp-internal/templates, not this module
yaak-templates = { path = "../yaak-templates", default-features = false }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1"
+1 -1
View File
@@ -3,4 +3,4 @@
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
// nowhere else: it owns a SQLite database, and there must be exactly one of it
// per origin.
export { blob_delete, blob_get, blob_put, boot, rpc } from "./pkg";
export { blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc } from "./pkg";
+11
View File
@@ -25,6 +25,17 @@ export function blob_put(id: string, bytes: Uint8Array): void;
*/
export function boot(): Promise<void>;
/**
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the send proxy.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
*/
export function prepare_http_send(payload: any): Promise<any>;
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
+35 -19
View File
@@ -62,6 +62,22 @@ export function boot() {
return ret;
}
/**
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the send proxy.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
* @param {any} payload
* @returns {Promise<any>}
*/
export function prepare_http_send(payload) {
const ret = wasm.prepare_http_send(payload);
return ret;
}
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
@@ -496,7 +512,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(a, state0.b, arg0, arg1);
return wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -681,23 +697,23 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
console.warn(arg0, arg1);
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1103, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
return ret;
}
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
return ret;
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 177, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h400c17219073e521);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 198, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
return ret;
}
export function __wbindgen_cast_0000000000000005(arg0) {
@@ -734,30 +750,30 @@ export function __wbindgen_init_externref_table() {
table.set(offset + 2, true);
table.set(offset + 3, false);
}
function wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3);
}
Binary file not shown.
+6 -5
View File
@@ -5,6 +5,7 @@ export const blob_delete: (a: number, b: number) => [number, number];
export const blob_get: (a: number, b: number) => [number, number, number, number];
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
export const boot: () => any;
export const prepare_http_send: (a: any) => any;
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
export const rust_sqlite_wasm_abort: () => void;
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
@@ -16,11 +17,11 @@ export const rust_sqlite_wasm_malloc: (a: number) => number;
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
export const sqlite3_os_end: () => number;
export const sqlite3_os_init: () => number;
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h400c17219073e521: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_exn_store: (a: number) => void;
+201 -3
View File
@@ -12,8 +12,10 @@
//! JavaScript side owns that; this crate assumes it is the only writer.
//!
//! The command surface is deliberately narrow: what the frontend needs to keep
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
//! everything else with a socket or a filesystem behind it lives elsewhere.
//! its model store coherent, blob storage, and the "prepare" half of a send
//! (resolve, inherit, render — see [`prepare_http_send`]). Putting bytes on the
//! network, plugins, git, sync and everything else with a socket or a
//! filesystem behind it lives elsewhere.
// Nothing in here means anything off wasm32, and building it there would drag
// SQLite's wasm C shim into a native compile. So on any other target the crate
@@ -24,12 +26,19 @@ use std::cell::RefCell;
use std::sync::mpsc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::AnyModel;
use yaak_models::cookies::apply_cookie_changes;
use yaak_models::models::{
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
HttpSendSettings,
};
use yaak_models::models_ops;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request;
use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_templates::{RenderOptions, TemplateCallback};
/// Names inside the VFS, not paths on any disk. Two files because the desktop
/// keeps two: models in one, blobs in the other.
@@ -209,6 +218,28 @@ struct UpsertIntrospectionReq {
content: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResponseIdReq {
response_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistSendCookiesReq {
cookie_jar_id: String,
before: Vec<Cookie>,
after: Vec<Cookie>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InsertResponseEventsReq {
response_id: String,
workspace_id: String,
events: Vec<HttpResponseEventData>,
}
fn dispatch(
host: &Host,
cmd: &str,
@@ -313,6 +344,48 @@ fn dispatch(
// Nothing here can open a socket, so no connection ever produced any.
"models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()),
"web_get_http_request" => {
let req: RequestIdReq = from_js(payload)?;
to_json(host.queries.connect().get_http_request(&req.request_id).map_err(js_error)?)
}
"cmd_get_http_response_events" => {
let req: ResponseIdReq = from_js(payload)?;
to_json(
host.queries
.connect()
.list_http_response_events(&req.response_id)
.map_err(js_error)?,
)
}
// The cookies a send set or cleared, applied to the jar as it is *now* rather than
// written over it, so an edit made while the send was in flight survives.
"web_persist_send_cookies" => {
let req: PersistSendCookiesReq = from_js(payload)?;
if req.before == req.after {
return to_json(());
}
let db = host.queries.connect();
let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?;
let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after);
db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?;
to_json(())
}
// The tab's half of the send timeline: the events the proxy streamed back, recorded
// under the response they belong to. Same rows the desktop's send task writes, and the
// writes fan out to every tab as `model_writes` like any other.
"web_insert_http_response_events" => {
let req: InsertResponseEventsReq = from_js(payload)?;
let db = host.queries.connect();
for event in req.events {
let model = HttpResponseEvent::new(&req.response_id, &req.workspace_id, event);
db.upsert_http_response_event(&model, source).map_err(js_error)?;
}
to_json(())
}
"cmd_get_workspace_meta" => {
let req: WorkspaceIdReq = from_js(payload)?;
let db = host.queries.connect();
@@ -346,6 +419,131 @@ fn dispatch(
}
}
/* -------------------------------------------------------------------------- */
/* Preparing a send */
/* -------------------------------------------------------------------------- */
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PrepareHttpSendReq {
request_id: String,
environment_id: Option<String>,
cookie_jar_id: Option<String>,
}
/// Everything a send needs that lives in the database, resolved and rendered: the desktop's
/// `HttpSendInputs`, in the shape a tab hands to the proxy and keeps for itself.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PreparedHttpSend {
/// The request with inherited headers and authentication applied and every template
/// rendered. What the proxy sends, and what the response records as its request.
request: HttpRequest,
settings: HttpSendSettings,
/// The `* Setting name=value` timeline lines the desktop writes at the top of a send,
/// sources and all. The tab records them before the proxy's own events.
setting_events: Vec<HttpResponseEventData>,
/// The jar the send starts with, so the tab can write it back with the proxy's changes.
cookie_jar: Option<CookieJar>,
}
/// A template callback for a host with no plugins. Variables render; a function is a clear
/// refusal naming the function, so the user knows what the request needs rather than seeing
/// an empty string sent in its place.
struct NoPluginsCallback;
impl TemplateCallback for NoPluginsCallback {
fn run(
&self,
fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> + Send {
let message = format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
Plugins aren't available in the browser yet"
);
async move { Err(yaak_templates::error::Error::RenderError(message)) }
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> yaak_templates::error::Result<String> {
Ok(arg_value.to_string())
}
}
/// Resolve and render a request for sending, exactly as the desktop does before it puts the
/// request on the network: the environment chain, inherited headers and auth, request
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
/// posts to the send proxy.
///
/// Refuses, with a message the user can act on, when the request needs something this host
/// doesn't have: an authentication plugin, or a template function.
#[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
let req: PrepareHttpSendReq = from_js(payload)?;
// Everything from the database first, then release the host borrow before rendering.
let (request, environment_chain, settings, cookie_jar) = with_host(|host| {
let db = host.queries.connect();
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
let environment_chain = db
.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
req.environment_id.as_deref(),
)
.map_err(js_error)?;
let (authentication_type, authentication, _auth_context_id) =
db.resolve_auth_for_http_request(&request).map_err(js_error)?;
let headers = db.resolve_headers_for_http_request(&request).map_err(js_error)?;
let settings = db.resolve_settings_for_http_request(&request).map_err(js_error)?;
let cookie_jar = match req.cookie_jar_id.as_deref() {
Some(id) => Some(db.get_cookie_jar(id).map_err(js_error)?),
None => None,
};
let request = HttpRequest { authentication_type, authentication, headers, ..request };
Ok((request, environment_chain, settings, cookie_jar))
})?;
let rendered = render_http_request(
&request,
environment_chain,
&NoPluginsCallback,
&RenderOptions::throw(),
)
.await
.map_err(js_error)?;
// Authentication is applied by a plugin on the desktop. There is no plugin here, and a
// request sent without the auth it asked for is worse than one refused with the reason.
let auth_disabled =
rendered.authentication.get("disabled").and_then(|v| v.as_bool()) == Some(true);
if let Some(auth_type) = rendered.authentication_type.as_deref()
&& auth_type != "none"
&& !auth_disabled
{
return Err(js_error(format!(
"This request uses {auth_type} authentication, which needs plugins. \
Plugins aren't available in the browser yet"
)));
}
let prepared = PreparedHttpSend {
request: rendered,
settings: HttpSendSettings::from(&settings),
setting_events: settings.timeline_events(),
cookie_jar,
};
// JSON-compatible, as `rpc` does: the tab posts this to the proxy with `JSON.stringify`,
// and the default serializer's `Map` for the request body would stringify to `{}`.
use serde::Serialize as _;
prepared.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_error)
}
/* -------------------------------------------------------------------------- */
/* Blobs */
/* -------------------------------------------------------------------------- */
-1
View File
@@ -2,7 +2,6 @@ pub mod error;
pub mod export;
pub mod import;
pub mod plugin_events;
pub mod render;
pub mod response_body;
pub mod send;
-217
View File
@@ -1,217 +0,0 @@
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_models::models::{
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
pub async fn render_http_request<T: TemplateCallback>(
request: &HttpRequest,
environment_chain: Vec<Environment>,
callback: &T,
options: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for parameter in request.url_parameters.clone() {
if !parameter.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: parameter.enabled,
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
id: parameter.id,
})
}
let mut headers = Vec::new();
for header in request.headers.clone() {
if !header.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: header.enabled,
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
id: header.id,
})
}
let mut body = BTreeMap::new();
for (key, value) in request.body.clone() {
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match request.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(template)) => {
disabled = parse_and_render(template.as_str(), vars, callback, options)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (key, value) in request.authentication.clone() {
if key == "disabled" {
auth.insert(key, Value::Bool(false));
} else {
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
}
}
auth
};
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}
+22 -55
View File
@@ -1,4 +1,3 @@
use crate::render::render_http_request;
use async_trait::async_trait;
use log::warn;
use std::path::{Path, PathBuf};
@@ -25,10 +24,11 @@ use yaak_http::types::{
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::{
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,
ResolvedHttpRequestSettings, ResolvedSetting,
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
ProxySettingAuth, ResolvedHttpRequestSettings,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request;
use yaak_models::util::{UpdateSource, generate_prefixed_id};
use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
@@ -193,6 +193,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
proxy: runtime_config.proxy.clone(),
client_certificate,
dns_overrides: runtime_config.dns_overrides.clone(),
address_filter: None,
})
.await?;
@@ -715,36 +716,24 @@ pub async fn send_http_request<T: TemplateCallback>(
let started_at = Instant::now();
let request_started_url = sendable_request.url.clone();
send_setting_event(
&event_tx,
"validate_certificates",
resolved_settings.validate_certificates.value.to_string(),
&resolved_settings.validate_certificates,
);
send_setting_event(
&event_tx,
"redirects",
sendable_request.options.follow_redirects.to_string(),
&resolved_settings.follow_redirects,
);
send_setting_event(
&event_tx,
"timeout",
timeout_setting_value(sendable_request.options.timeout),
&resolved_settings.request_timeout,
);
send_setting_event(
&event_tx,
"send_cookies",
cookie_behavior.send_cookies.to_string(),
&resolved_settings.send_cookies,
);
send_setting_event(
&event_tx,
"store_cookies",
cookie_behavior.store_cookies.to_string(),
&resolved_settings.store_cookies,
);
for event in resolved_settings.timeline_events() {
if let HttpResponseEventData::Setting {
name,
value,
source_model,
source_id,
source_name,
} = event
{
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
name,
value,
source_model,
source_id,
source_name,
});
}
}
let mut http_response =
match executor.send(sendable_request, event_tx, cookie_behavior.clone()).await {
@@ -1130,28 +1119,6 @@ pub fn persist_cookies_after_send(
Ok(())
}
fn send_setting_event<T>(
event_tx: &mpsc::Sender<SenderHttpResponseEvent>,
name: impl Into<String>,
value: impl Into<String>,
setting: &ResolvedSetting<T>,
) {
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
name: name.into(),
value: value.into(),
source_model: Some(setting.source_model.clone()),
source_id: setting.source_id.clone(),
source_name: setting.source_name.clone(),
});
}
fn timeout_setting_value(timeout: Option<Duration>) -> String {
match timeout {
Some(timeout) if !timeout.is_zero() => format!("{timeout:?}"),
_ => "Infinity".to_string(),
}
}
fn proxy_setting_from_settings(proxy: Option<ProxySetting>) -> HttpConnectionProxySetting {
match proxy {
None => HttpConnectionProxySetting::System,