From 2e14df119e8c707f153bf37fe1113ec4adfdcd65 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 13:12:26 -0700 Subject: [PATCH] fix(models): preserve duplicate request headers --- crates/yaak-models/src/queries/folders.rs | 6 +- .../yaak-models/src/queries/grpc_requests.rs | 6 +- .../yaak-models/src/queries/http_requests.rs | 47 +++++++++++-- crates/yaak-models/src/queries/mod.rs | 70 ++++++++++++++----- .../src/queries/websocket_requests.rs | 10 +-- crates/yaak-models/src/queries/workspaces.rs | 5 +- 6 files changed, 105 insertions(+), 39 deletions(-) diff --git a/crates/yaak-models/src/queries/folders.rs b/crates/yaak-models/src/queries/folders.rs index a26e3379..bc5bd389 100644 --- a/crates/yaak-models/src/queries/folders.rs +++ b/crates/yaak-models/src/queries/folders.rs @@ -1,4 +1,4 @@ -use super::conflict_free_name; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::connection_or_tx::ConnectionOrTx; use crate::error::Result; @@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut folder.headers.clone()); - - Ok(headers) + Ok(merge_headers(headers, folder.headers.clone())) } pub fn resolve_settings_for_folder( diff --git a/crates/yaak-models/src/queries/grpc_requests.rs b/crates/yaak-models/src/queries/grpc_requests.rs index f54635b4..593e128f 100644 --- a/crates/yaak-models/src/queries/grpc_requests.rs +++ b/crates/yaak-models/src/queries/grpc_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> { metadata.append(&mut workspace_metadata); } - metadata.append(&mut grpc_request.metadata.clone()); - - Ok(dedupe_headers(metadata)) + Ok(merge_headers(metadata, grpc_request.metadata.clone())) } pub fn resolve_settings_for_grpc_request( diff --git a/crates/yaak-models/src/queries/http_requests.rs b/crates/yaak-models/src/queries/http_requests.rs index 6130ec67..d4e0d6d7 100644 --- a/crates/yaak-models/src/queries/http_requests.rs +++ b/crates/yaak-models/src/queries/http_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut http_request.headers.clone()); - - Ok(dedupe_headers(headers)) + Ok(merge_headers(headers, http_request.headers.clone())) } pub fn resolve_settings_for_http_request( @@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> { Ok(children) } } + +#[cfg(test)] +mod tests { + use crate::init_in_memory; + use crate::models::{HttpRequest, HttpRequestHeader}; + + #[test] + fn request_resolution_preserves_duplicate_request_headers() { + let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0); + let request = HttpRequest { + workspace_id: workspace.id, + headers: vec![ + HttpRequestHeader { + name: "Cookie".to_string(), + value: "required=1".to_string(), + ..Default::default() + }, + HttpRequestHeader { + enabled: false, + name: "Cookie".to_string(), + value: "optional=1".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + + let resolved = db.resolve_headers_for_http_request(&request).expect("Failed to resolve"); + let cookies = resolved + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("cookie")) + .collect::>(); + + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0].value, "required=1"); + assert_eq!(cookies[1].value, "optional=1"); + assert!(!cookies[1].enabled); + } +} diff --git a/crates/yaak-models/src/queries/mod.rs b/crates/yaak-models/src/queries/mod.rs index 2b6bdc43..e2e8dabc 100644 --- a/crates/yaak-models/src/queries/mod.rs +++ b/crates/yaak-models/src/queries/mod.rs @@ -28,21 +28,59 @@ pub(crate) use duplicate_name::conflict_free_name; const MAX_HISTORY_ITEMS: usize = 20; use crate::models::HttpRequestHeader; -use std::collections::HashMap; +use std::collections::HashSet; -/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value. -/// Preserves the order of first occurrence for each header name. -pub(crate) fn dedupe_headers(headers: Vec) -> Vec { - let mut index_by_name: HashMap = HashMap::new(); - let mut deduped: Vec = Vec::new(); - for header in headers { - let key = header.name.to_lowercase(); - if let Some(&idx) = index_by_name.get(&key) { - deduped[idx] = header; - } else { - index_by_name.insert(key, deduped.len()); - deduped.push(header); - } - } - deduped +/// Merge a more-specific header layer over its parent. Names in the child replace +/// inherited values case-insensitively, while duplicates declared together in +/// either layer remain independent entries. +pub(crate) fn merge_headers( + mut parent: Vec, + child: Vec, +) -> Vec { + let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::>(); + parent.retain(|header| !child_names.contains(&header.name.to_lowercase())); + parent.extend(child); + parent +} + +#[cfg(test)] +mod tests { + use super::merge_headers; + use crate::models::HttpRequestHeader; + + fn header(name: &str, value: &str) -> HttpRequestHeader { + HttpRequestHeader { name: name.to_string(), value: value.to_string(), ..Default::default() } + } + + #[test] + fn preserves_duplicate_headers_declared_in_one_layer() { + let merged = merge_headers( + vec![header("Cookie", "inherited=1")], + vec![ + header("Cookie", "required=1"), + header("cookie", "optional=1"), + ], + ); + + assert_eq!( + merged.iter().map(|header| header.value.as_str()).collect::>(), + vec!["required=1", "optional=1"], + ); + } + + #[test] + fn child_names_override_parent_names_without_affecting_other_headers() { + let merged = merge_headers( + vec![header("Accept", "*/*"), header("X-Parent", "kept")], + vec![header("accept", "application/json")], + ); + + assert_eq!( + merged + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(), + vec![("X-Parent", "kept"), ("accept", "application/json")], + ); + } } diff --git a/crates/yaak-models/src/queries/websocket_requests.rs b/crates/yaak-models/src/queries/websocket_requests.rs index 3ef4c16d..1dcf752f 100644 --- a/crates/yaak-models/src/queries/websocket_requests.rs +++ b/crates/yaak-models/src/queries/websocket_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> { &self, websocket_request: &WebsocketRequest, ) -> Result> { - let workspace = self.get_workspace(&websocket_request.workspace_id)?; - // Resolved headers should be from furthest to closest ancestor, to override logically. let mut headers = Vec::new(); - headers.append(&mut workspace.headers.clone()); - if let Some(folder_id) = websocket_request.folder_id.clone() { let parent_folder = self.get_folder(&folder_id)?; let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; @@ -120,9 +116,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut websocket_request.headers.clone()); - - Ok(dedupe_headers(headers)) + Ok(merge_headers(headers, websocket_request.headers.clone())) } pub fn resolve_settings_for_websocket_request( diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index 50deb620..6ed7a262 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -1,3 +1,4 @@ +use super::merge_headers; use crate::blob_manager::BlobManager; use crate::client_db::ClientDb; use crate::error::Result; @@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> { } pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec { - let mut headers = default_headers(); - headers.extend(workspace.headers.clone()); - headers + merge_headers(default_headers(), workspace.headers.clone()) } pub fn resolve_settings_for_workspace(