diff --git a/docs/spec/options/hurl/discard_body.option b/docs/spec/options/hurl/discard_body.option new file mode 100644 index 00000000000..47c340604b5 --- /dev/null +++ b/docs/spec/options/hurl/discard_body.option @@ -0,0 +1,12 @@ +name: discard_body +long: discard-body +help: Discard response body +help_heading: HTTP options +config_file: true +env_var: HURL_DISCARD_BODY +--- +Discard response body. + +If there are no asserts and reports relying on the response body, this option can free the memory consumed by the response body earlier. + +Without this option, Hurl keeps the response body in memory until the program ends. This can consume a large amount of memory if the payload is large, particularly when using parallel or repeated execution modes. diff --git a/docs/spec/options/hurl/truncate_body.option b/docs/spec/options/hurl/truncate_body.option new file mode 100644 index 00000000000..815be53be17 --- /dev/null +++ b/docs/spec/options/hurl/truncate_body.option @@ -0,0 +1,8 @@ +name: truncate_body +long: truncate-body +help: Truncate response body +help_heading: HTTP options +config_file: true +env_var: HURL_TRUNCATE_BODY +--- +Truncate the response body. diff --git a/integration/hurl/tests_ok/discard_body/discard_body.err.pattern b/integration/hurl/tests_ok/discard_body/discard_body.err.pattern new file mode 100644 index 00000000000..5e846c515f2 --- /dev/null +++ b/integration/hurl/tests_ok/discard_body/discard_body.err.pattern @@ -0,0 +1,27 @@ +* ------------------------------------------------------------------------------ +* Executing entry 1 +* +* Cookie store: +* +* Request: +* GET http://localhost:8000/discard-body +* +* Request can be run with the following curl command: +* curl 'http://localhost:8000/discard-body' +* +> GET /discard-body HTTP/1.1 +> Host: localhost:8000 +> Accept: */* +> User-Agent: hurl/8.1.0 +> +* Response: +* +< HTTP/1.1 200 OK +< Content-Length: 12 +< Content-Type: text/html; charset=utf-8 +< Date: <<<.*?>>> +< Server: Flask Server +< Via: waitress +< +* Received 0 bytes in <<<\d+>>> ms +* diff --git a/integration/hurl/tests_ok/discard_body/discard_body.hurl b/integration/hurl/tests_ok/discard_body/discard_body.hurl new file mode 100644 index 00000000000..55506a2dac4 --- /dev/null +++ b/integration/hurl/tests_ok/discard_body/discard_body.hurl @@ -0,0 +1,2 @@ +GET http://localhost:8000/discard-body +HTTP 200 diff --git a/integration/hurl/tests_ok/discard_body/discard_body.out b/integration/hurl/tests_ok/discard_body/discard_body.out new file mode 100644 index 00000000000..e69de29bb2d diff --git a/integration/hurl/tests_ok/discard_body/discard_body.ps1 b/integration/hurl/tests_ok/discard_body/discard_body.ps1 new file mode 100644 index 00000000000..f71b146b704 --- /dev/null +++ b/integration/hurl/tests_ok/discard_body/discard_body.ps1 @@ -0,0 +1,4 @@ +Set-StrictMode -Version latest +$ErrorActionPreference = 'Stop' + +hurl --verbose --discard-body Accept tests_ok/discard_body/discard_body.hurl diff --git a/integration/hurl/tests_ok/discard_body/discard_body.py b/integration/hurl/tests_ok/discard_body/discard_body.py new file mode 100644 index 00000000000..fb1f1993275 --- /dev/null +++ b/integration/hurl/tests_ok/discard_body/discard_body.py @@ -0,0 +1,6 @@ +from app import app +from flask import request + +@app.route("/discard-body") +def discard_body(): + return "Hello World!" diff --git a/integration/hurl/tests_ok/discard_body/discard_body.sh b/integration/hurl/tests_ok/discard_body/discard_body.sh new file mode 100755 index 00000000000..a79476742d7 --- /dev/null +++ b/integration/hurl/tests_ok/discard_body/discard_body.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -Eeuo pipefail + +hurl --verbose --discard-body tests_ok/discard_body/discard_body.hurl diff --git a/integration/hurl/tests_ok/discard_body/hurl/config b/integration/hurl/tests_ok/discard_body/hurl/config new file mode 100644 index 00000000000..7937db74009 --- /dev/null +++ b/integration/hurl/tests_ok/discard_body/hurl/config @@ -0,0 +1 @@ +--discard-body diff --git a/integration/hurl/tests_pty/help/help.out b/integration/hurl/tests_pty/help/help.out index c3189383974..0a35fd1b21c 100644 --- a/integration/hurl/tests_pty/help/help.out +++ b/integration/hurl/tests_pty/help/help.out @@ -87,6 +87,10 @@ HTTP options: Add basic Authentication header to each request -A, --user-agent Specify the User-Agent string to send to the HTTP server + --discard-body + Discard the response body + --truncate-body + Truncate the response body Output options: --color Colorize output diff --git a/packages/hurl/src/cli/options/args.rs b/packages/hurl/src/cli/options/args.rs index b83b824f8be..76647df5bf1 100644 --- a/packages/hurl/src/cli/options/args.rs +++ b/packages/hurl/src/cli/options/args.rs @@ -109,6 +109,8 @@ pub fn parse_cli_args( .arg(commands::verbose()) .arg(commands::very_verbose()) .arg(commands::verbosity()) + .arg(commands::discard_body()) + .arg(commands::truncate_body()) // Run options .arg(commands::continue_on_error()) .arg(commands::delay()) @@ -223,6 +225,7 @@ fn parse_arg_matches( let no_cookie_store = no_cookie_store(arg_matches, default_options.no_cookie_store); let no_headers = no_headers(arg_matches, default_options.no_headers); let no_proxy = no_proxy(arg_matches, default_options.no_proxy); + let discard_body = discard_body(arg_matches, default_options.discard_body); let ntlm = ntlm(arg_matches, default_options.ntlm); let parallel = parallel(arg_matches, default_options.parallel); let path_as_is = path_as_is(arg_matches, default_options.path_as_is); @@ -248,6 +251,7 @@ fn parse_arg_matches( let user_agent = user_agent(arg_matches, default_options.user_agent); let variables = variables(arg_matches, default_options.variables)?; let verbosity = verbosity(arg_matches, default_options.verbosity); + let truncate_body = truncate_body(arg_matches, default_options.truncate_body); Ok(CliOptions { aws_sigv4, @@ -292,6 +296,7 @@ fn parse_arg_matches( no_cookie_store, no_headers, no_proxy, + discard_body, ntlm, path_as_is, pinned_pub_key, @@ -318,6 +323,7 @@ fn parse_arg_matches( variables, verbosity, jobs, + truncate_body, }) } @@ -772,6 +778,14 @@ fn no_proxy(arg_matches: &ArgMatches, default_value: Option) -> Option(arg_matches, "no_proxy").or(default_value) } +fn discard_body(arg_matches: &ArgMatches, default_value: bool) -> bool { + if has_flag(arg_matches, "discard_body") { + true + } else { + default_value + } +} + fn ntlm(arg_matches: &ArgMatches, default_value: bool) -> bool { if has_flag(arg_matches, "ntlm") { true @@ -841,6 +855,10 @@ fn proxy(arg_matches: &ArgMatches, default_value: Option) -> Option(arg_matches, "proxy").or(default_value) } +fn truncate_body(arg_matches: &ArgMatches, default_value: Option) -> Option { + get::(arg_matches, "truncate_body").or(default_value) +} + fn proxy_headers(arg_matches: &ArgMatches, default_value: Vec) -> Vec { let mut proxy_headers = default_value; if let Some(proxy_header) = get_strings(arg_matches, "proxy_header") { diff --git a/packages/hurl/src/cli/options/commands.rs b/packages/hurl/src/cli/options/commands.rs index 9f08f0df518..e9d1d8e50d0 100644 --- a/packages/hurl/src/cli/options/commands.rs +++ b/packages/hurl/src/cli/options/commands.rs @@ -583,6 +583,25 @@ pub fn report_json() -> clap::Arg { .num_args(1) } +pub fn discard_body() -> clap::Arg { + clap::Arg::new("discard_body") + .long("discard-body") + .help("Discard the response body") + .help_heading("HTTP options") + .action(clap::ArgAction::SetTrue) +} + +pub fn truncate_body() -> clap::Arg { + clap::Arg::new("truncate_body") + .long("truncate-body") + .help("Truncate the response body") + .help_heading("HTTP options") + .value_parser(clap::value_parser!(i64)) + .value_name("SIZE") + .num_args(1) + .allow_negative_numbers(true) +} + pub fn report_junit() -> clap::Arg { clap::Arg::new("report_junit") .long("report-junit") diff --git a/packages/hurl/src/cli/options/config_file/mod.rs b/packages/hurl/src/cli/options/config_file/mod.rs index 7de582cd639..5f67013dced 100644 --- a/packages/hurl/src/cli/options/config_file/mod.rs +++ b/packages/hurl/src/cli/options/config_file/mod.rs @@ -241,6 +241,16 @@ fn parse_option(reader: &mut Reader, options: &mut CliOptions) -> Result<(), Con options.insecure = true; Ok(()) } + "discard_body" => { + expect_no_value(reader)?; + options.discard_body = false; + Ok(()) + } + "truncate_body" => { + expect_no_value(reader)?; + options.truncate_body = None; + Ok(()) + } "http1.0" => { expect_no_value(reader)?; options.http_version = Some(HttpVersion::V10); @@ -439,7 +449,6 @@ fn parse_option(reader: &mut Reader, options: &mut CliOptions) -> Result<(), Con #[cfg(test)] mod tests { - use super::*; use crate::cli::options::Verbosity; use hurl_core::reader::Pos; diff --git a/packages/hurl/src/cli/options/mod.rs b/packages/hurl/src/cli/options/mod.rs index c0fe0ba923c..8028a122884 100644 --- a/packages/hurl/src/cli/options/mod.rs +++ b/packages/hurl/src/cli/options/mod.rs @@ -92,6 +92,7 @@ pub struct CliOptions { pub no_cookie_store: bool, pub no_headers: Vec, pub no_proxy: Option, + pub discard_body: bool, pub ntlm: bool, pub output: Option, pub output_type: OutputType, @@ -117,6 +118,7 @@ pub struct CliOptions { pub user_agent: Option, pub variables: HashMap, pub verbosity: Option, + pub truncate_body: Option, } /// Log verbosity level @@ -294,6 +296,7 @@ impl Default for CliOptions { no_cookie_store: false, no_headers: Vec::new(), no_proxy: None, + discard_body: false, ntlm: false, output: None, output_type: OutputType::ResponseBody, @@ -319,6 +322,7 @@ impl Default for CliOptions { user_agent: None, variables: HashMap::new(), verbosity: None, + truncate_body: None, } } } @@ -432,6 +436,8 @@ impl CliOptions { let use_cookie_store = !self.no_cookie_store; let user = self.user.clone(); let user_agent = self.user_agent.clone(); + let discard_body = self.discard_body; + let truncate_body = self.truncate_body; Ok(RunnerOptionsBuilder::new() .aws_sigv4(aws_sigv4) @@ -466,6 +472,7 @@ impl CliOptions { .use_jsonpath_coercion(use_jsonpath_coercion) .no_proxy(no_proxy) .no_headers(no_headers) + .discard_body(discard_body) .ntlm(ntlm) .output(output) .path_as_is(path_as_is) @@ -483,6 +490,7 @@ impl CliOptions { .use_cookie_store(use_cookie_store) .user(user) .user_agent(user_agent) + .truncate_body(truncate_body) .build()) } diff --git a/packages/hurl/src/http/client.rs b/packages/hurl/src/http/client.rs index ae474c87b40..87072e42b41 100644 --- a/packages/hurl/src/http/client.rs +++ b/packages/hurl/src/http/client.rs @@ -293,10 +293,26 @@ impl Client { } _ => {} })?; - transfer.write_function(|data| { - response_body.extend(data); - Ok(data.len()) - })?; + + if !options.discard_body { + transfer.write_function(|data| { + if let Some(limit) = options.truncate_body { + let limit = limit as usize; + let current_len = response_body.len(); + + if current_len < limit { + let remaining = limit - current_len; + let bytes_to_take = data.len().min(remaining); + + response_body.extend_from_slice(&data[..bytes_to_take]); + } + } else { + response_body.extend(data); + } + + Ok(data.len()) + })?; + } if let Err(e) = transfer.perform() { let code = e.code() as i32; // due to windows build diff --git a/packages/hurl/src/http/curl_cmd.rs b/packages/hurl/src/http/curl_cmd.rs index e60ff913094..ab4d630b2a9 100644 --- a/packages/hurl/src/http/curl_cmd.rs +++ b/packages/hurl/src/http/curl_cmd.rs @@ -765,6 +765,8 @@ mod tests { user: Some("user:password".to_string()), user_agent: Some("my-useragent".to_string()), verbosity: None, + discard_body: false, + truncate_body: None, }; let cmd = CurlCmd::new(&request, &cookie_store, &context_dir, None, &options); diff --git a/packages/hurl/src/http/options.rs b/packages/hurl/src/http/options.rs index 2f60295027a..56fa3be7f66 100644 --- a/packages/hurl/src/http/options.rs +++ b/packages/hurl/src/http/options.rs @@ -66,6 +66,8 @@ pub struct ClientOptions { pub user: Option, pub user_agent: Option, pub verbosity: Option, + pub discard_body: bool, + pub truncate_body: Option, } // FIXME/ we could implement copy here @@ -117,6 +119,8 @@ impl Default for ClientOptions { user: None, user_agent: None, verbosity: None, + discard_body: false, + truncate_body: None, } } } diff --git a/packages/hurl/src/runner/entry.rs b/packages/hurl/src/runner/entry.rs index 46887f36d10..e3feb28d597 100644 --- a/packages/hurl/src/runner/entry.rs +++ b/packages/hurl/src/runner/entry.rs @@ -299,6 +299,8 @@ impl ClientOptions { Verbosity::Verbose => http::Verbosity::Verbose, Verbosity::VeryVerbose => http::Verbosity::VeryVerbose, }), + discard_body: runner_options.discard_body, + truncate_body: runner_options.truncate_body, } } } diff --git a/packages/hurl/src/runner/runner_options.rs b/packages/hurl/src/runner/runner_options.rs index e87f973ff2e..ad5b658beb0 100644 --- a/packages/hurl/src/runner/runner_options.rs +++ b/packages/hurl/src/runner/runner_options.rs @@ -15,9 +15,8 @@ * limitations under the License. * */ -use std::time::Duration; - use hurl_core::types::{BytesPerSec, Count}; +use std::time::Duration; use crate::http::{FollowLocation, HeaderVec, IpResolve, RequestedHttpVersion}; use crate::pretty::PrettyMode; @@ -79,6 +78,8 @@ pub struct RunnerOptionsBuilder { use_jsonpath_coercion: bool, user: Option, user_agent: Option, + discard_body: bool, + truncate_body: Option, } impl Default for RunnerOptionsBuilder { @@ -136,6 +137,8 @@ impl Default for RunnerOptionsBuilder { use_jsonpath_coercion: true, user: None, user_agent: None, + discard_body: false, + truncate_body: None, } } } @@ -337,6 +340,11 @@ impl RunnerOptionsBuilder { self } + pub fn truncate_body(&mut self, truncate_body: Option) -> &mut Self { + self.truncate_body = truncate_body; + self + } + /// Enables HTTP Digest authentication. pub fn digest(&mut self, digest: bool) -> &mut Self { self.digest = digest; @@ -373,6 +381,11 @@ impl RunnerOptionsBuilder { self } + pub fn discard_body(&mut self, discard_body: bool) -> &mut Self { + self.discard_body = discard_body; + self + } + /// Sets list of hosts which do not use a proxy. pub fn no_proxy(&mut self, no_proxy: Option) -> &mut Self { self.no_proxy = no_proxy; @@ -553,6 +566,8 @@ impl RunnerOptionsBuilder { use_jsonpath_coercion: self.use_jsonpath_coercion, user: self.user.clone(), user_agent: self.user_agent.clone(), + discard_body: self.discard_body, + truncate_body: self.truncate_body, } } } @@ -668,6 +683,10 @@ pub struct RunnerOptions { pub(crate) user: Option, /// Specifies the User-Agent string to send to the HTTP server. pub(crate) user_agent: Option, + /// Specifies whether to discard the response body. + pub(crate) discard_body: bool, + /// Specifies the maximum number of bytes to truncate the response body. + pub(crate) truncate_body: Option, } impl Default for RunnerOptions { diff --git a/packages/hurl/tests/sample.rs b/packages/hurl/tests/sample.rs index 2d274de6015..dcf8ee2b3dd 100644 --- a/packages/hurl/tests/sample.rs +++ b/packages/hurl/tests/sample.rs @@ -122,6 +122,7 @@ fn simple_sample() { .unix_socket(None) .user(None) .user_agent(None) + .discard_body(false) .build(); let logger_opts = LoggerOptionsBuilder::new()