mirror of
https://github.com/openai/codex.git
synced 2026-05-01 09:56:37 +00:00
[2/4] Implement executor HTTP request runner (#18582)
### Why Remote streamable HTTP MCP needs the executor to perform ordinary HTTP requests on the executor side. This keeps network placement aligned with `experimental_environment = "remote"` without adding MCP-specific executor APIs. ### What - Add an executor-side `http/request` runner backed by `reqwest`. - Validate request method and URL scheme, preserving the transport boundary at plain HTTP. - Return buffered responses for ordinary calls and emit ordered `http/request/bodyDelta` notifications for streaming responses. - Register the request handler in the exec-server router. - Document the runner entrypoint, conversion helpers, body-stream bridge, notification sender, timeout behavior, and new integration-test helpers. - Add exec-server integration tests with the existing websocket harness and a local TCP HTTP peer for buffered and streamed responses, with comments spelling out what each test proves and its setup/exercise/assert phases. ### Stack 1. #18581 protocol 2. #18582 runner 3. #18583 RMCP client 4. #18584 manager wiring and local/remote coverage ### Verification - `just fmt` - `cargo check -p codex-exec-server -p codex-rmcp-client --tests` - `cargo check -p codex-core --test all` compile-only - `git diff --check` - Online full CI is running from the `full-ci` branch, including the remote Rust test job. Co-authored-by: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
568
codex-rs/exec-server/tests/http_request.rs
Normal file
568
codex-rs/exec-server/tests/http_request.rs
Normal file
@@ -0,0 +1,568 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_exec_server::HttpHeader;
|
||||
use codex_exec_server::HttpRequestBodyDeltaNotification;
|
||||
use codex_exec_server::HttpRequestParams;
|
||||
use codex_exec_server::HttpRequestResponse;
|
||||
use codex_exec_server::InitializeParams;
|
||||
use common::exec_server::ExecServerHarness;
|
||||
use common::exec_server::exec_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// HTTP request captured by the ad-hoc TCP server in these integration tests.
|
||||
#[derive(Debug)]
|
||||
struct CapturedHttpRequest {
|
||||
stream: TcpStream,
|
||||
request_line: String,
|
||||
headers: BTreeMap<String, String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// What this tests: a real exec-server websocket `http/request` performs one
|
||||
/// HTTP request through the runner and returns the complete response body in
|
||||
/// the JSON-RPC response.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_server_http_request_buffers_response_body() -> anyhow::Result<()> {
|
||||
// Phase 1: start exec-server and complete the JSON-RPC handshake.
|
||||
let mut server = exec_server().await?;
|
||||
initialize_exec_server(&mut server).await?;
|
||||
|
||||
// Phase 2: start a local HTTP peer and ask exec-server to POST to it.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let url = format!("http://{}/mcp?case=buffered", listener.local_addr()?);
|
||||
let http_request_id = server
|
||||
.send_request(
|
||||
"http/request",
|
||||
serde_json::to_value(HttpRequestParams {
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: vec![HttpHeader {
|
||||
name: "x-codex-test".to_string(),
|
||||
value: "buffered".to_string(),
|
||||
}],
|
||||
body: Some(b"request-body".to_vec().into()),
|
||||
timeout_ms: Some(5_000),
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 3: assert the HTTP peer observes the expected method, path,
|
||||
// headers, and body before returning a fixed-length response.
|
||||
let captured = accept_http_request(&listener).await?;
|
||||
assert_eq!(
|
||||
(
|
||||
captured.request_line.as_str(),
|
||||
captured.headers.get("x-codex-test").map(String::as_str),
|
||||
captured.body.as_slice(),
|
||||
),
|
||||
(
|
||||
"POST /mcp?case=buffered HTTP/1.1",
|
||||
Some("buffered"),
|
||||
b"request-body".as_slice(),
|
||||
)
|
||||
);
|
||||
respond_with_status_and_headers(
|
||||
captured.stream,
|
||||
"201 Created",
|
||||
&[("x-mcp-test", "buffered")],
|
||||
b"response-body",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 4: assert exec-server returns status, response headers, and the
|
||||
// full response body in the JSON-RPC result.
|
||||
let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?;
|
||||
assert_eq!(
|
||||
(
|
||||
response.status,
|
||||
response_header(&response.headers, "x-mcp-test"),
|
||||
response.body.into_inner(),
|
||||
),
|
||||
(201, Some("buffered".to_string()), b"response-body".to_vec(),)
|
||||
);
|
||||
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What this tests: a real exec-server websocket `http/request` can return
|
||||
/// response headers immediately and stream the response body as ordered
|
||||
/// `http/request/bodyDelta` notifications.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_server_http_request_streams_response_body_notifications() -> anyhow::Result<()> {
|
||||
// Phase 1: start exec-server and complete the JSON-RPC handshake.
|
||||
let mut server = exec_server().await?;
|
||||
initialize_exec_server(&mut server).await?;
|
||||
|
||||
// Phase 2: start a local HTTP peer and ask exec-server for a streamed GET.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let url = format!("http://{}/mcp?case=streaming", listener.local_addr()?);
|
||||
let http_request_id = server
|
||||
.send_request(
|
||||
"http/request",
|
||||
serde_json::to_value(HttpRequestParams {
|
||||
method: "GET".to_string(),
|
||||
url,
|
||||
headers: vec![HttpHeader {
|
||||
name: "accept".to_string(),
|
||||
value: "text/event-stream".to_string(),
|
||||
}],
|
||||
body: None,
|
||||
timeout_ms: Some(5_000),
|
||||
request_id: "stream-1".to_string(),
|
||||
stream_response: true,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 3: assert the HTTP peer observes the expected request and then
|
||||
// respond with chunked transfer encoding to exercise streaming.
|
||||
let captured = accept_http_request(&listener).await?;
|
||||
assert_eq!(
|
||||
(
|
||||
captured.request_line.as_str(),
|
||||
captured.headers.get("accept").map(String::as_str),
|
||||
captured.body,
|
||||
),
|
||||
(
|
||||
"GET /mcp?case=streaming HTTP/1.1",
|
||||
Some("text/event-stream"),
|
||||
Vec::new(),
|
||||
)
|
||||
);
|
||||
respond_with_chunked_body(
|
||||
captured.stream,
|
||||
&[("x-mcp-test", "streaming")],
|
||||
&[b"hello ".as_slice(), b"world".as_slice()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 4: assert the JSON-RPC response reaches the wire before any body
|
||||
// delta notifications, and that it contains status and headers but no
|
||||
// buffered body when streaming is requested.
|
||||
let first_event = server.next_event().await?;
|
||||
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = first_event else {
|
||||
anyhow::bail!("expected http/request response before body deltas, got {first_event:?}");
|
||||
};
|
||||
assert_eq!(id, http_request_id);
|
||||
let response: HttpRequestResponse = serde_json::from_value(result)?;
|
||||
assert_eq!(
|
||||
(
|
||||
response.status,
|
||||
response_header(&response.headers, "x-mcp-test"),
|
||||
response.body.into_inner(),
|
||||
),
|
||||
(200, Some("streaming".to_string()), Vec::new())
|
||||
);
|
||||
|
||||
// Phase 5: assert the body notifications are contiguous, ordered, and end
|
||||
// with a clean terminal frame.
|
||||
let deltas = collect_response_body_deltas(&mut server, "stream-1").await?;
|
||||
let seqs = deltas.iter().map(|delta| delta.seq).collect::<Vec<_>>();
|
||||
let body = deltas
|
||||
.iter()
|
||||
.flat_map(|delta| delta.delta.clone().into_inner())
|
||||
.collect::<Vec<_>>();
|
||||
let terminal = deltas.last().map(|delta| (delta.done, delta.error.clone()));
|
||||
let expected_seqs = (1..=deltas.len() as u64).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
(seqs, body, terminal),
|
||||
(expected_seqs, b"hello world".to_vec(), Some((true, None)))
|
||||
);
|
||||
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What this tests: streamed `requestId`s stay reserved until the body stream
|
||||
/// finishes, so a second in-flight request cannot reuse the same id.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_server_http_request_rejects_duplicate_stream_request_ids() -> anyhow::Result<()> {
|
||||
let mut server = exec_server().await?;
|
||||
initialize_exec_server(&mut server).await?;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let url = format!(
|
||||
"http://{}/mcp?case=duplicate-stream-id",
|
||||
listener.local_addr()?
|
||||
);
|
||||
let first_request_id = server
|
||||
.send_request(
|
||||
"http/request",
|
||||
serde_json::to_value(HttpRequestParams {
|
||||
method: "GET".to_string(),
|
||||
url: url.clone(),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
request_id: "stream-dup".to_string(),
|
||||
stream_response: true,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let captured = accept_http_request(&listener).await?;
|
||||
let (finish_tx, finish_rx) = oneshot::channel();
|
||||
let response_task = tokio::spawn(async move {
|
||||
respond_with_chunked_body_until_finish(captured.stream, &[], &[b"hello"], finish_rx).await
|
||||
});
|
||||
|
||||
let _: HttpRequestResponse = wait_for_response(&mut server, first_request_id).await?;
|
||||
|
||||
let duplicate_request_id = server
|
||||
.send_request(
|
||||
"http/request",
|
||||
serde_json::to_value(HttpRequestParams {
|
||||
method: "GET".to_string(),
|
||||
url,
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
request_id: "stream-dup".to_string(),
|
||||
stream_response: true,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let duplicate_response = server
|
||||
.wait_for_event(|event| {
|
||||
matches!(
|
||||
event,
|
||||
JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &duplicate_request_id
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
let JSONRPCMessage::Error(JSONRPCError { error, .. }) = duplicate_response else {
|
||||
anyhow::bail!("expected duplicate requestId error response");
|
||||
};
|
||||
assert_eq!(error.code, -32602);
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"http/request streamResponse requestId `stream-dup` is already active"
|
||||
);
|
||||
|
||||
finish_tx
|
||||
.send(())
|
||||
.expect("response task should still be waiting");
|
||||
response_task.await??;
|
||||
let _ = collect_response_body_deltas(&mut server, "stream-dup").await?;
|
||||
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What this tests: omitting `timeoutMs` leaves the request unbounded, while
|
||||
/// an explicit short timeout still fails the same delayed response.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_server_http_request_honors_optional_timeout() -> anyhow::Result<()> {
|
||||
let mut server = exec_server().await?;
|
||||
initialize_exec_server(&mut server).await?;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let delayed_url = format!(
|
||||
"http://{}/mcp?case=optional-timeout",
|
||||
listener.local_addr()?
|
||||
);
|
||||
let no_timeout_request_id = server
|
||||
.send_request(
|
||||
"http/request",
|
||||
serde_json::to_value(HttpRequestParams {
|
||||
method: "GET".to_string(),
|
||||
url: delayed_url.clone(),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let captured = accept_http_request(&listener).await?;
|
||||
let delayed_response = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
respond_with_status_and_headers(captured.stream, "200 OK", &[], b"slow-success").await
|
||||
});
|
||||
let response: HttpRequestResponse =
|
||||
wait_for_response(&mut server, no_timeout_request_id).await?;
|
||||
assert_eq!(response.body.into_inner(), b"slow-success".to_vec());
|
||||
delayed_response.await??;
|
||||
|
||||
let timeout_request_id = server
|
||||
.send_request(
|
||||
"http/request",
|
||||
serde_json::to_value(HttpRequestParams {
|
||||
method: "GET".to_string(),
|
||||
url: delayed_url,
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
timeout_ms: Some(10),
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: false,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let captured = accept_http_request(&listener).await?;
|
||||
let delayed_timeout_response = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
respond_with_status_and_headers(captured.stream, "200 OK", &[], b"too-late").await
|
||||
});
|
||||
let error = wait_for_error_response(&mut server, timeout_request_id).await?;
|
||||
assert_eq!(error.code, -32603);
|
||||
assert!(
|
||||
error.message.starts_with("http/request failed: "),
|
||||
"unexpected timeout error: {}",
|
||||
error.message
|
||||
);
|
||||
match delayed_timeout_response.await? {
|
||||
Ok(()) => {}
|
||||
Err(err) if is_expected_peer_disconnect(&err) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs the JSON-RPC initialize handshake required before executor methods.
|
||||
async fn initialize_exec_server(server: &mut ExecServerHarness) -> anyhow::Result<()> {
|
||||
let initialize_id = server
|
||||
.send_request(
|
||||
"initialize",
|
||||
serde_json::to_value(InitializeParams {
|
||||
client_name: "exec-server-http-test".to_string(),
|
||||
resume_session_id: None,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
let _: Value = wait_for_response(server, initialize_id).await?;
|
||||
server
|
||||
.send_notification("initialized", serde_json::json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Waits for a typed JSON-RPC response with the requested id.
|
||||
async fn wait_for_response<T>(
|
||||
server: &mut ExecServerHarness,
|
||||
request_id: RequestId,
|
||||
) -> anyhow::Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let response = server
|
||||
.wait_for_event(|event| {
|
||||
matches!(
|
||||
event,
|
||||
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &request_id
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else {
|
||||
anyhow::bail!("expected JSON-RPC response for {request_id:?}");
|
||||
};
|
||||
Ok(serde_json::from_value(result)?)
|
||||
}
|
||||
|
||||
/// Waits for a JSON-RPC error with the requested id.
|
||||
async fn wait_for_error_response(
|
||||
server: &mut ExecServerHarness,
|
||||
request_id: RequestId,
|
||||
) -> anyhow::Result<codex_app_server_protocol::JSONRPCErrorError> {
|
||||
let response = server
|
||||
.wait_for_event(|event| {
|
||||
matches!(
|
||||
event,
|
||||
JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &request_id
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
let JSONRPCMessage::Error(JSONRPCError { error, .. }) = response else {
|
||||
anyhow::bail!("expected JSON-RPC error for {request_id:?}");
|
||||
};
|
||||
Ok(error)
|
||||
}
|
||||
|
||||
/// Accepts one HTTP/1.1 request and captures its wire-visible fields.
|
||||
async fn accept_http_request(listener: &TcpListener) -> anyhow::Result<CapturedHttpRequest> {
|
||||
let (stream, _) = timeout(Duration::from_secs(5), listener.accept()).await??;
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line).await?;
|
||||
let request_line = request_line.trim_end_matches("\r\n").to_string();
|
||||
|
||||
let mut headers = BTreeMap::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
let line = line.trim_end_matches("\r\n");
|
||||
let (name, value) = line
|
||||
.split_once(':')
|
||||
.ok_or_else(|| anyhow::anyhow!("HTTP header should contain colon: {line}"))?;
|
||||
headers.insert(name.to_ascii_lowercase(), value.trim().to_string());
|
||||
}
|
||||
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let mut body = vec![0; content_length];
|
||||
reader.read_exact(&mut body).await?;
|
||||
|
||||
Ok(CapturedHttpRequest {
|
||||
stream: reader.into_inner(),
|
||||
request_line,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes a fixed-length HTTP response to the captured request stream.
|
||||
async fn respond_with_status_and_headers(
|
||||
mut stream: TcpStream,
|
||||
status: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let extra_headers = headers
|
||||
.iter()
|
||||
.map(|(name, value)| format!("{name}: {value}\r\n"))
|
||||
.collect::<String>();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n",
|
||||
body.len(),
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
stream.write_all(body).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_expected_peer_disconnect(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
cause
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|io_err| {
|
||||
matches!(
|
||||
io_err.kind(),
|
||||
ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::UnexpectedEof
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes a chunked HTTP response so reqwest must drive the streaming path.
|
||||
async fn respond_with_chunked_body(
|
||||
mut stream: TcpStream,
|
||||
headers: &[(&str, &str)],
|
||||
chunks: &[&[u8]],
|
||||
) -> anyhow::Result<()> {
|
||||
let extra_headers = headers
|
||||
.iter()
|
||||
.map(|(name, value)| format!("{name}: {value}\r\n"))
|
||||
.collect::<String>();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ntransfer-encoding: chunked\r\nconnection: close\r\n{extra_headers}\r\n",
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
for chunk in chunks {
|
||||
stream
|
||||
.write_all(format!("{:x}\r\n", chunk.len()).as_bytes())
|
||||
.await?;
|
||||
stream.write_all(chunk).await?;
|
||||
stream.write_all(b"\r\n").await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
stream.write_all(b"0\r\n\r\n").await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a chunked response and keeps the stream open until the test allows EOF.
|
||||
async fn respond_with_chunked_body_until_finish(
|
||||
mut stream: TcpStream,
|
||||
headers: &[(&str, &str)],
|
||||
chunks: &[&[u8]],
|
||||
finish_rx: oneshot::Receiver<()>,
|
||||
) -> anyhow::Result<()> {
|
||||
let extra_headers = headers
|
||||
.iter()
|
||||
.map(|(name, value)| format!("{name}: {value}\r\n"))
|
||||
.collect::<String>();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ntransfer-encoding: chunked\r\nconnection: close\r\n{extra_headers}\r\n",
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
for chunk in chunks {
|
||||
stream
|
||||
.write_all(format!("{:x}\r\n", chunk.len()).as_bytes())
|
||||
.await?;
|
||||
stream.write_all(chunk).await?;
|
||||
stream.write_all(b"\r\n").await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
finish_rx.await?;
|
||||
stream.write_all(b"0\r\n\r\n").await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collects streamed response-body notifications until the terminal frame.
|
||||
async fn collect_response_body_deltas(
|
||||
server: &mut ExecServerHarness,
|
||||
request_id: &str,
|
||||
) -> anyhow::Result<Vec<HttpRequestBodyDeltaNotification>> {
|
||||
let mut deltas = Vec::new();
|
||||
loop {
|
||||
let event = server.next_event().await?;
|
||||
let JSONRPCMessage::Notification(JSONRPCNotification { method, params }) = event else {
|
||||
anyhow::bail!("expected http/request body delta notification, got {event:?}");
|
||||
};
|
||||
assert_eq!(method, "http/request/bodyDelta");
|
||||
let delta: HttpRequestBodyDeltaNotification =
|
||||
serde_json::from_value(params.unwrap_or(Value::Null))?;
|
||||
assert_eq!(delta.request_id, request_id);
|
||||
|
||||
let done = delta.done;
|
||||
deltas.push(delta);
|
||||
if done {
|
||||
return Ok(deltas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a response header value without depending on header-name casing.
|
||||
fn response_header(headers: &[HttpHeader], name: &str) -> Option<String> {
|
||||
headers
|
||||
.iter()
|
||||
.find(|header| header.name.eq_ignore_ascii_case(name))
|
||||
.map(|header| header.value.clone())
|
||||
}
|
||||
Reference in New Issue
Block a user