Skip to content

Commit 844ffe4

Browse files
committed
Support specifying header order
1 parent bff698a commit 844ffe4

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ client = Wreq::Client.new(
7777
zstd: true, # enable zstd decompression
7878
emulation: "chrome_143", # browser emulation (enabled by default)
7979
emulation_os: "windows", # OS emulation: windows, macos (default), linux, android, ios
80+
header_order: [ # wire order of headers (names only, case-sensitive)
81+
"host", # listed headers appear first in the given order, remaining
82+
"user-agent", # emulation headers follow.
83+
"accept",
84+
],
8085
headers: { # default headers for all requests
8186
"Accept" => "application/json"
8287
}

ext/wreq_rb/src/client.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use magnus::{
1010
};
1111
use tokio::runtime::Runtime;
1212
use tokio_util::sync::CancellationToken;
13-
use wreq::header::{HeaderMap, HeaderName, HeaderValue};
13+
use wreq::header::{HeaderMap, HeaderName, HeaderValue, OrigHeaderMap};
1414
use wreq_util::{Emulation as BrowserEmulation, EmulationOS, EmulationOption};
1515

1616
use crate::error::{generic_error, to_magnus_error};
@@ -192,6 +192,12 @@ impl Client {
192192
let mut builder = wreq::Client::builder();
193193

194194
if let Some(opts) = opts {
195+
// Apply header_order BEFORE emulation so the user's ordering takes precedence
196+
if let Some(ary) = hash_get_array(&opts, "header_order")? {
197+
let orig = array_to_orig_header_map(ary)?;
198+
builder = builder.orig_headers(orig);
199+
}
200+
195201
if let Some(val) = hash_get_value(&opts, "emulation")? {
196202
let ruby = unsafe { Ruby::get_unchecked() };
197203
if val.is_kind_of(ruby.class_false_class()) {
@@ -536,6 +542,27 @@ fn hash_get_hash(hash: &RHash, key: &str) -> Result<Option<RHash>, magnus::Error
536542
}
537543
}
538544

545+
fn hash_get_array(hash: &RHash, key: &str) -> Result<Option<RArray>, magnus::Error> {
546+
match hash_get_value(hash, key)? {
547+
Some(v) => Ok(Some(RArray::try_convert(v)?)),
548+
None => Ok(None),
549+
}
550+
}
551+
552+
fn array_to_orig_header_map(ary: RArray) -> Result<OrigHeaderMap, magnus::Error> {
553+
let mut orig = OrigHeaderMap::with_capacity(ary.len());
554+
for elem in ary.into_iter() {
555+
let ruby = unsafe { Ruby::get_unchecked() };
556+
let name_str: String = if elem.is_kind_of(ruby.class_symbol()) {
557+
elem.funcall("to_s", ())?
558+
} else {
559+
TryConvert::try_convert(elem)?
560+
};
561+
orig.insert(name_str);
562+
}
563+
Ok(orig)
564+
}
565+
539566
fn hash_to_header_map(hash: &RHash) -> Result<HeaderMap, magnus::Error> {
540567
let mut hmap = HeaderMap::new();
541568
hash.foreach(|k: Value, v: Value| {

test/client_test.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,70 @@ def test_default_headers_with_mixed_types
4747
assert_nil body["headers"]["X-Nil"]
4848
assert_equal "ok", body["headers"]["X-Str"]
4949
end
50+
51+
def test_header_order
52+
order = ["x-zzz", "x-aaa", "x-ccc"]
53+
client = Wreq::Client.new(
54+
emulation: false,
55+
http1_only: true,
56+
no_proxy: true,
57+
header_order: order,
58+
headers: { "x-aaa" => "1", "x-ccc" => "2", "x-zzz" => "3" }
59+
)
60+
received = capture_wire_headers { |url| client.get(url) }
61+
62+
positions = order.map { |h| received.index(h) }.compact
63+
assert_equal order.size, positions.size,
64+
"Not all target headers found in: #{received.inspect}"
65+
assert_equal positions.sort, positions,
66+
"Expected #{order.inspect} in order, got positions #{positions.inspect} in: #{received.inspect}"
67+
end
68+
69+
def test_header_order_takes_precedence_over_emulation
70+
# Chrome emulation normally puts "user-agent" before "accept". We reverse
71+
# that relationship to prove the user's header_order wins.
72+
order = ["host", "accept", "user-agent"]
73+
client = Wreq::Client.new(
74+
emulation: "chrome_145",
75+
http1_only: true,
76+
no_proxy: true,
77+
header_order: order
78+
)
79+
received = capture_wire_headers { |url| client.get(url) }
80+
81+
positions = order.map { |h| received.index(h) }.compact
82+
assert_equal order.size, positions.size,
83+
"Not all target headers found in: #{received.inspect}"
84+
assert_equal positions.sort, positions,
85+
"Expected user's header_order #{order.inspect} to take precedence; " \
86+
"got positions #{positions.inspect} in: #{received.inspect}"
87+
end
88+
89+
private
90+
91+
# Spins up a local TCP server, yields the port formatted into a URL, captures
92+
# the header names from the raw HTTP/1.1 request, then tears down the server.
93+
def capture_wire_headers
94+
require "socket"
95+
server = TCPServer.new("127.0.0.1", 0)
96+
port = server.addr[1]
97+
received = []
98+
t = Thread.new do
99+
conn = server.accept
100+
conn.gets # skip request line
101+
loop do
102+
line = conn.gets&.chomp
103+
break if line.nil? || line.empty?
104+
received << line.split(":", 2).first.downcase
105+
end
106+
conn.write "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
107+
conn.close
108+
rescue
109+
conn&.close
110+
end
111+
yield format("http://127.0.0.1:#{port}/")
112+
t.join(5)
113+
server.close
114+
received
115+
end
50116
end

0 commit comments

Comments
 (0)