Skip to content

Commit 66c13d7

Browse files
authored
Port OpenAPI improvements from the integration branch (#602)
1 parent 388dd88 commit 66c13d7

9 files changed

Lines changed: 1736 additions & 212 deletions

File tree

crates/yaak-http/src/types.rs

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,19 @@ impl SendableHttpRequest {
7878
}
7979

8080
pub fn insert_header(&mut self, header: (String, String)) {
81+
if header.0.eq_ignore_ascii_case("cookie") {
82+
if let Some(existing) =
83+
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie"))
84+
{
85+
existing.1 = format!("{}; {}", existing.1, header.1);
86+
} else {
87+
self.headers.push(header);
88+
}
89+
return;
90+
}
91+
8192
if let Some(existing) =
82-
self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase())
93+
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(&header.0))
8394
{
8495
existing.1 = header.1;
8596
} else {
@@ -205,16 +216,23 @@ fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Va
205216
}
206217

207218
fn build_headers(r: &HttpRequest) -> Vec<(String, String)> {
208-
r.headers
209-
.iter()
210-
.filter_map(|h| {
211-
if h.enabled && !h.name.is_empty() {
212-
Some((h.name.clone(), h.value.clone()))
213-
} else {
214-
None
219+
// RFC 6265 allows only one Cookie field, so enabled Cookie rows fold into
220+
// the first one
221+
let mut headers: Vec<(String, String)> = Vec::new();
222+
for h in &r.headers {
223+
if !h.enabled || h.name.is_empty() {
224+
continue;
225+
}
226+
if h.name.eq_ignore_ascii_case("cookie") {
227+
if let Some(existing) = headers.iter_mut().find(|e| e.0.eq_ignore_ascii_case("cookie"))
228+
{
229+
existing.1 = format!("{}; {}", existing.1, h.value);
230+
continue;
215231
}
216-
})
217-
.collect()
232+
}
233+
headers.push((h.name.clone(), h.value.clone()));
234+
}
235+
headers
218236
}
219237

220238
async fn build_body(
@@ -494,7 +512,114 @@ mod tests {
494512
use bytes::Bytes;
495513
use serde_json::json;
496514
use std::collections::BTreeMap;
497-
use yaak_models::models::{HttpRequest, HttpUrlParameter};
515+
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
516+
517+
#[tokio::test]
518+
async fn test_sendable_request_preserves_independent_cookie_enabled_states() {
519+
let request = HttpRequest {
520+
url: "https://example.com/api".to_string(),
521+
headers: vec![
522+
HttpRequestHeader {
523+
enabled: true,
524+
name: "Cookie".to_string(),
525+
value: "session=abc".to_string(),
526+
id: None,
527+
},
528+
HttpRequestHeader {
529+
enabled: false,
530+
name: "Cookie".to_string(),
531+
value: "debug=verbose".to_string(),
532+
id: None,
533+
},
534+
],
535+
..Default::default()
536+
};
537+
538+
let sendable =
539+
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
540+
.await
541+
.unwrap();
542+
543+
assert_eq!(sendable.headers, vec![("Cookie".to_string(), "session=abc".to_string())]);
544+
}
545+
546+
#[tokio::test]
547+
async fn test_sendable_request_merges_enabled_cookie_rows_into_one_field() {
548+
let request = HttpRequest {
549+
url: "https://example.com/api".to_string(),
550+
headers: vec![
551+
HttpRequestHeader {
552+
enabled: true,
553+
name: "Cookie".to_string(),
554+
value: "session=abc".to_string(),
555+
id: None,
556+
},
557+
HttpRequestHeader {
558+
enabled: false,
559+
name: "Cookie".to_string(),
560+
value: "debug=verbose".to_string(),
561+
id: None,
562+
},
563+
HttpRequestHeader {
564+
enabled: true,
565+
name: "cookie".to_string(),
566+
value: "theme=dark".to_string(),
567+
id: None,
568+
},
569+
],
570+
..Default::default()
571+
};
572+
573+
let sendable =
574+
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
575+
.await
576+
.unwrap();
577+
578+
assert_eq!(
579+
sendable.headers,
580+
vec![("Cookie".to_string(), "session=abc; theme=dark".to_string())],
581+
);
582+
}
583+
584+
#[test]
585+
fn test_insert_header_appends_authentication_cookie() {
586+
let mut request = SendableHttpRequest {
587+
headers: vec![
588+
("Cookie".to_string(), "session=abc".to_string()),
589+
("Cookie".to_string(), "theme=dark".to_string()),
590+
],
591+
..Default::default()
592+
};
593+
594+
request.insert_header(("cookie".to_string(), "api_key=secret".to_string()));
595+
596+
assert_eq!(
597+
request.headers,
598+
vec![
599+
("Cookie".to_string(), "session=abc; api_key=secret".to_string()),
600+
("Cookie".to_string(), "theme=dark".to_string()),
601+
],
602+
);
603+
}
604+
605+
#[tokio::test]
606+
async fn test_sendable_request_preserves_serialized_path_delimiters() {
607+
let request = HttpRequest {
608+
url: "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2"
609+
.to_string(),
610+
..Default::default()
611+
};
612+
613+
let sendable =
614+
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
615+
.await
616+
.unwrap();
617+
618+
assert_eq!(
619+
sendable.url,
620+
"https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2",
621+
);
622+
}
498623

499624
#[test]
500625
fn test_build_url_no_params() {

crates/yaak-models/src/queries/folders.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::conflict_free_name;
1+
use super::{conflict_free_name, merge_headers};
22
use crate::client_db::ClientDb;
33
use crate::connection_or_tx::ConnectionOrTx;
44
use crate::error::Result;
@@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> {
144144
headers.append(&mut workspace_headers);
145145
}
146146

147-
headers.append(&mut folder.headers.clone());
148-
149-
Ok(headers)
147+
Ok(merge_headers(headers, folder.headers.clone()))
150148
}
151149

152150
pub fn resolve_settings_for_folder(

crates/yaak-models/src/queries/grpc_requests.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{conflict_free_name, dedupe_headers};
1+
use super::{conflict_free_name, merge_headers};
22
use crate::client_db::ClientDb;
33
use crate::error::Result;
44
use crate::models::{
@@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> {
110110
metadata.append(&mut workspace_metadata);
111111
}
112112

113-
metadata.append(&mut grpc_request.metadata.clone());
114-
115-
Ok(dedupe_headers(metadata))
113+
Ok(merge_headers(metadata, grpc_request.metadata.clone()))
116114
}
117115

118116
pub fn resolve_settings_for_grpc_request(

crates/yaak-models/src/queries/http_requests.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{conflict_free_name, dedupe_headers};
1+
use super::{conflict_free_name, merge_headers};
22
use crate::client_db::ClientDb;
33
use crate::error::Result;
44
use crate::models::{
@@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> {
9696
headers.append(&mut workspace_headers);
9797
}
9898

99-
headers.append(&mut http_request.headers.clone());
100-
101-
Ok(dedupe_headers(headers))
99+
Ok(merge_headers(headers, http_request.headers.clone()))
102100
}
103101

104102
pub fn resolve_settings_for_http_request(
@@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> {
172170
Ok(children)
173171
}
174172
}
173+
174+
#[cfg(test)]
175+
mod tests {
176+
use crate::init_in_memory;
177+
use crate::models::{HttpRequest, HttpRequestHeader};
178+
179+
#[test]
180+
fn request_resolution_preserves_duplicate_request_headers() {
181+
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
182+
let db = query_manager.connect();
183+
let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0);
184+
let request = HttpRequest {
185+
workspace_id: workspace.id,
186+
headers: vec![
187+
HttpRequestHeader {
188+
name: "Cookie".to_string(),
189+
value: "required=1".to_string(),
190+
..Default::default()
191+
},
192+
HttpRequestHeader {
193+
enabled: false,
194+
name: "Cookie".to_string(),
195+
value: "optional=1".to_string(),
196+
..Default::default()
197+
},
198+
],
199+
..Default::default()
200+
};
201+
202+
let resolved = db.resolve_headers_for_http_request(&request).expect("Failed to resolve");
203+
let cookies = resolved
204+
.iter()
205+
.filter(|header| header.name.eq_ignore_ascii_case("cookie"))
206+
.collect::<Vec<_>>();
207+
208+
assert_eq!(cookies.len(), 2);
209+
assert_eq!(cookies[0].value, "required=1");
210+
assert_eq!(cookies[1].value, "optional=1");
211+
assert!(!cookies[1].enabled);
212+
}
213+
}

crates/yaak-models/src/queries/mod.rs

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,59 @@ pub(crate) use duplicate_name::conflict_free_name;
2828
const MAX_HISTORY_ITEMS: usize = 20;
2929

3030
use crate::models::HttpRequestHeader;
31-
use std::collections::HashMap;
32-
33-
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
34-
/// Preserves the order of first occurrence for each header name.
35-
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
36-
let mut index_by_name: HashMap<String, usize> = HashMap::new();
37-
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
38-
for header in headers {
39-
let key = header.name.to_lowercase();
40-
if let Some(&idx) = index_by_name.get(&key) {
41-
deduped[idx] = header;
42-
} else {
43-
index_by_name.insert(key, deduped.len());
44-
deduped.push(header);
45-
}
31+
use std::collections::HashSet;
32+
33+
/// Merge a more-specific header layer over its parent. Names in the child replace
34+
/// inherited values case-insensitively, while duplicates declared together in
35+
/// either layer remain independent entries.
36+
pub(crate) fn merge_headers(
37+
mut parent: Vec<HttpRequestHeader>,
38+
child: Vec<HttpRequestHeader>,
39+
) -> Vec<HttpRequestHeader> {
40+
let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
41+
parent.retain(|header| !child_names.contains(&header.name.to_lowercase()));
42+
parent.extend(child);
43+
parent
44+
}
45+
46+
#[cfg(test)]
47+
mod tests {
48+
use super::merge_headers;
49+
use crate::models::HttpRequestHeader;
50+
51+
fn header(name: &str, value: &str) -> HttpRequestHeader {
52+
HttpRequestHeader { name: name.to_string(), value: value.to_string(), ..Default::default() }
53+
}
54+
55+
#[test]
56+
fn preserves_duplicate_headers_declared_in_one_layer() {
57+
let merged = merge_headers(
58+
vec![header("Cookie", "inherited=1")],
59+
vec![
60+
header("Cookie", "required=1"),
61+
header("cookie", "optional=1"),
62+
],
63+
);
64+
65+
assert_eq!(
66+
merged.iter().map(|header| header.value.as_str()).collect::<Vec<_>>(),
67+
vec!["required=1", "optional=1"],
68+
);
69+
}
70+
71+
#[test]
72+
fn child_names_override_parent_names_without_affecting_other_headers() {
73+
let merged = merge_headers(
74+
vec![header("Accept", "*/*"), header("X-Parent", "kept")],
75+
vec![header("accept", "application/json")],
76+
);
77+
78+
assert_eq!(
79+
merged
80+
.iter()
81+
.map(|header| (header.name.as_str(), header.value.as_str()))
82+
.collect::<Vec<_>>(),
83+
vec![("X-Parent", "kept"), ("accept", "application/json")],
84+
);
4685
}
47-
deduped
4886
}

crates/yaak-models/src/queries/websocket_requests.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{conflict_free_name, dedupe_headers};
1+
use super::{conflict_free_name, merge_headers};
22
use crate::client_db::ClientDb;
33
use crate::error::Result;
44
use crate::models::{
@@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> {
103103
&self,
104104
websocket_request: &WebsocketRequest,
105105
) -> Result<Vec<HttpRequestHeader>> {
106-
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
107-
108106
// Resolved headers should be from furthest to closest ancestor, to override logically.
109107
let mut headers = Vec::new();
110108

111-
headers.append(&mut workspace.headers.clone());
112-
113109
if let Some(folder_id) = websocket_request.folder_id.clone() {
114110
let parent_folder = self.get_folder(&folder_id)?;
115111
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
@@ -120,9 +116,7 @@ impl<'a> ClientDb<'a> {
120116
headers.append(&mut workspace_headers);
121117
}
122118

123-
headers.append(&mut websocket_request.headers.clone());
124-
125-
Ok(dedupe_headers(headers))
119+
Ok(merge_headers(headers, websocket_request.headers.clone()))
126120
}
127121

128122
pub fn resolve_settings_for_websocket_request(

crates/yaak-models/src/queries/workspaces.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use super::merge_headers;
12
use crate::blob_manager::BlobManager;
23
use crate::client_db::ClientDb;
34
use crate::error::Result;
@@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> {
144145
}
145146

146147
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
147-
let mut headers = default_headers();
148-
headers.extend(workspace.headers.clone());
149-
headers
148+
merge_headers(default_headers(), workspace.headers.clone())
150149
}
151150

152151
pub fn resolve_settings_for_workspace(

0 commit comments

Comments
 (0)