fix(models): preserve duplicate request headers

This commit is contained in:
Gregory Schier
2026-08-19 13:12:26 -07:00
parent 9644594df6
commit 2e14df119e
6 changed files with 105 additions and 39 deletions
+2 -4
View File
@@ -1,4 +1,4 @@
use super::conflict_free_name; use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::connection_or_tx::ConnectionOrTx; use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result; use crate::error::Result;
@@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> {
headers.append(&mut workspace_headers); headers.append(&mut workspace_headers);
} }
headers.append(&mut folder.headers.clone()); Ok(merge_headers(headers, folder.headers.clone()))
Ok(headers)
} }
pub fn resolve_settings_for_folder( pub fn resolve_settings_for_folder(
@@ -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::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
@@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> {
metadata.append(&mut workspace_metadata); metadata.append(&mut workspace_metadata);
} }
metadata.append(&mut grpc_request.metadata.clone()); Ok(merge_headers(metadata, grpc_request.metadata.clone()))
Ok(dedupe_headers(metadata))
} }
pub fn resolve_settings_for_grpc_request( pub fn resolve_settings_for_grpc_request(
@@ -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::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
@@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> {
headers.append(&mut workspace_headers); headers.append(&mut workspace_headers);
} }
headers.append(&mut http_request.headers.clone()); Ok(merge_headers(headers, http_request.headers.clone()))
Ok(dedupe_headers(headers))
} }
pub fn resolve_settings_for_http_request( pub fn resolve_settings_for_http_request(
@@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> {
Ok(children) 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::<Vec<_>>();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].value, "required=1");
assert_eq!(cookies[1].value, "optional=1");
assert!(!cookies[1].enabled);
}
}
+54 -16
View File
@@ -28,21 +28,59 @@ pub(crate) use duplicate_name::conflict_free_name;
const MAX_HISTORY_ITEMS: usize = 20; const MAX_HISTORY_ITEMS: usize = 20;
use crate::models::HttpRequestHeader; use crate::models::HttpRequestHeader;
use std::collections::HashMap; use std::collections::HashSet;
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value. /// Merge a more-specific header layer over its parent. Names in the child replace
/// Preserves the order of first occurrence for each header name. /// inherited values case-insensitively, while duplicates declared together in
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> { /// either layer remain independent entries.
let mut index_by_name: HashMap<String, usize> = HashMap::new(); pub(crate) fn merge_headers(
let mut deduped: Vec<HttpRequestHeader> = Vec::new(); mut parent: Vec<HttpRequestHeader>,
for header in headers { child: Vec<HttpRequestHeader>,
let key = header.name.to_lowercase(); ) -> Vec<HttpRequestHeader> {
if let Some(&idx) = index_by_name.get(&key) { let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
deduped[idx] = header; parent.retain(|header| !child_names.contains(&header.name.to_lowercase()));
} else { parent.extend(child);
index_by_name.insert(key, deduped.len()); parent
deduped.push(header); }
}
} #[cfg(test)]
deduped 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<_>>(),
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<_>>(),
vec![("X-Parent", "kept"), ("accept", "application/json")],
);
}
} }
@@ -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::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
@@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> {
&self, &self,
websocket_request: &WebsocketRequest, websocket_request: &WebsocketRequest,
) -> Result<Vec<HttpRequestHeader>> { ) -> Result<Vec<HttpRequestHeader>> {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
// Resolved headers should be from furthest to closest ancestor, to override logically. // Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new(); let mut headers = Vec::new();
headers.append(&mut workspace.headers.clone());
if let Some(folder_id) = websocket_request.folder_id.clone() { if let Some(folder_id) = websocket_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?; let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; 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 workspace_headers);
} }
headers.append(&mut websocket_request.headers.clone()); Ok(merge_headers(headers, websocket_request.headers.clone()))
Ok(dedupe_headers(headers))
} }
pub fn resolve_settings_for_websocket_request( pub fn resolve_settings_for_websocket_request(
+2 -3
View File
@@ -1,3 +1,4 @@
use super::merge_headers;
use crate::blob_manager::BlobManager; use crate::blob_manager::BlobManager;
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
@@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> {
} }
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> { pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
let mut headers = default_headers(); merge_headers(default_headers(), workspace.headers.clone())
headers.extend(workspace.headers.clone());
headers
} }
pub fn resolve_settings_for_workspace( pub fn resolve_settings_for_workspace(