Add --output option per request

This commit is contained in:
jcamiel 2023-11-27 18:05:45 +01:00
parent 8de89c16f9
commit 53d174588c
No known key found for this signature in database
GPG Key ID: 07FF11CFD55356CC
16 changed files with 238 additions and 59 deletions

View File

@ -341,29 +341,9 @@ impl Options {
let cacert_file = self.cacert_file.clone();
let client_cert_file = self.client_cert_file.clone();
let client_key_file = self.client_key_file.clone();
let connects_to = self.connects_to.clone();
let follow_location = self.follow_location;
let http_version = match self.http_version {
Some(version) => version.into(),
None => RequestedHttpVersion::default(),
};
let ip_resolve = match self.ip_resolve {
Some(ip) => ip.into(),
None => http::IpResolve::default(),
};
let insecure = self.insecure;
let max_redirect = self.max_redirect;
let path_as_is = self.path_as_is;
let proxy = self.proxy.clone();
let no_proxy = self.no_proxy.clone();
let cookie_input_file = self.cookie_input_file.clone();
let timeout = self.timeout;
let connect_timeout = self.connect_timeout;
let user = self.user.clone();
let user_agent = self.user_agent.clone();
let compressed = self.compressed;
let continue_on_error = self.continue_on_error;
let delay = self.delay;
let connect_timeout = self.connect_timeout;
let connects_to = self.connects_to.clone();
let file_root = match self.file_root {
Some(ref filename) => Path::new(filename),
None => {
@ -376,22 +356,73 @@ impl Options {
}
};
let context_dir = ContextDir::new(current_dir, file_root);
let pre_entry = if self.interactive {
Some(cli::interactive::pre_entry as fn(Entry) -> bool)
} else {
None
let continue_on_error = self.continue_on_error;
let cookie_input_file = self.cookie_input_file.clone();
let delay = self.delay;
let follow_location = self.follow_location;
let http_version = match self.http_version {
Some(version) => version.into(),
None => RequestedHttpVersion::default(),
};
let ignore_asserts = self.ignore_asserts;
let insecure = self.insecure;
let ip_resolve = match self.ip_resolve {
Some(ip) => ip.into(),
None => http::IpResolve::default(),
};
let max_redirect = self.max_redirect;
let no_proxy = self.no_proxy.clone();
// FIXME:
// When used globally (on the command line), `--output` writes the last successful request
// to `output` file. We don't want to output every entry's response, so we initialise
// output to `None`.
// The straightforward code should have been `let output = self.output;` but if we do this
// every entry's response would have been dumped to stdout.
//
// If we compare with `--compressed`:
//
// ```
// cli.compressed = true =>
// entry_1.compressed = true
// entry_2.compressed = true
// entry_2.overridden.compressed = false
// entry_3.compressed = true
// etc...
// entry_last.compressed = true
// ```
//
// whereas
//
// ```
// cli.output = /tmp/out.bin =>
// entry_1.output = None
// entry_2.output = None
// entry_2.overridden.output = /tmp/bar.bin
// entry_3.output = None
// etc...
// entry_last.output = /tmp/out.bin
// ```
let output = None;
let path_as_is = self.path_as_is;
let post_entry = if self.interactive {
Some(cli::interactive::post_entry as fn() -> bool)
} else {
None
};
let to_entry = self.to_entry;
let pre_entry = if self.interactive {
Some(cli::interactive::pre_entry as fn(Entry) -> bool)
} else {
None
};
let proxy = self.proxy.clone();
let resolves = self.resolves.clone();
let retry = self.retry;
let retry_interval = self.retry_interval;
let ignore_asserts = self.ignore_asserts;
let ssl_no_revoke = self.ssl_no_revoke;
let timeout = self.timeout;
let to_entry = self.to_entry;
let user = self.user.clone();
let user_agent = self.user_agent.clone();
RunnerOptionsBuilder::new()
.aws_sigv4(aws_sigv4)
@ -412,6 +443,7 @@ impl Options {
.ip_resolve(ip_resolve)
.max_redirect(max_redirect)
.no_proxy(no_proxy)
.output(output)
.path_as_is(path_as_is)
.post_entry(post_entry)
.pre_entry(pre_entry)

View File

@ -75,7 +75,7 @@ pub fn eval_file(filename: &Filename, context_dir: &ContextDir) -> Result<Vec<u8
return Err(Error::new(filename.source_info, inner, false));
}
let resolved_file = context_dir.get_path(&file);
let inner = RunnerError::FileReadAccess { value: file };
let inner = RunnerError::FileReadAccess { file };
match std::fs::read(resolved_file) {
Ok(value) => Ok(value),
Err(_) => Err(Error::new(filename.source_info, inner, false)),
@ -143,7 +143,7 @@ mod tests {
assert_eq!(
error.inner,
RunnerError::FileReadAccess {
value: "data.bin".to_string()
file: "data.bin".to_string()
}
);
assert_eq!(

View File

@ -91,10 +91,9 @@ pub fn run(
let calls = match http_client.execute_with_redirect(&http_request, &client_options, logger) {
Ok(calls) => calls,
Err(http_error) => {
let source_info = SourceInfo {
start: entry.request.url.source_info.start,
end: entry.request.url.source_info.end,
};
let start = entry.request.url.source_info.start;
let end = entry.request.url.source_info.end;
let source_info = SourceInfo::new(start, end);
let error = Error::new(source_info, http_error.into(), false);
return EntryResult {
entry_index,

View File

@ -65,7 +65,12 @@ pub enum RunnerError {
CouldNotParseResponse,
CouldNotUncompressResponse(String),
FileReadAccess {
value: String,
file: String,
},
// I/O write error on a path
FileWriteAccess {
file: String,
error: String,
},
FilterDecode(String),
FilterInvalidEncoding(String),
@ -128,6 +133,7 @@ impl hurl_core::error::Error for Error {
RunnerError::CouldNotParseResponse => "HTTP connection".to_string(),
RunnerError::CouldNotUncompressResponse(..) => "Decompression error".to_string(),
RunnerError::FileReadAccess { .. } => "File read access".to_string(),
RunnerError::FileWriteAccess { .. } => "File write access".to_string(),
RunnerError::FilterDecode { .. } => "Filter Error".to_string(),
RunnerError::FilterInvalidEncoding { .. } => "Filter Error".to_string(),
RunnerError::FilterInvalidInput { .. } => "Filter Error".to_string(),
@ -182,7 +188,10 @@ impl hurl_core::error::Error for Error {
RunnerError::CouldNotUncompressResponse(algorithm) => {
format!("could not uncompress response with {algorithm}")
}
RunnerError::FileReadAccess { value } => format!("file {value} can not be read"),
RunnerError::FileReadAccess { file } => format!("file {file} can not be read"),
RunnerError::FileWriteAccess { file, error } => {
format!("{file} can not be write ({error})")
}
RunnerError::FilterDecode(encoding) => {
format!("value can not be decoded with <{encoding}> encoding")
}

View File

@ -21,14 +21,13 @@ use std::time::Instant;
use chrono::Utc;
use hurl_core::ast::VersionValue::VersionAnyLegacy;
use hurl_core::ast::*;
use hurl_core::ast::{Body, Bytes, Entry, HurlFile, MultilineString, Request, Response, Retry};
use hurl_core::error::Error;
use hurl_core::parser;
use crate::http::Call;
use crate::runner::result::*;
use crate::runner::runner_options::RunnerOptions;
use crate::runner::{entry, options, Value};
use crate::runner::{entry, options, EntryResult, HurlResult, Value};
use crate::util::logger::{ErrorFormat, Logger, LoggerOptions, LoggerOptionsBuilder};
use crate::{http, runner};
@ -104,15 +103,19 @@ pub fn run(
let start = Instant::now();
let timestamp = Utc::now().timestamp();
// Main loop processing each entry.
// The `entry_index` is not always incremented of each loop tick: an entry can be retried upon
// errors for instance. Each entry is executed with options that are computed from the global
// runner options and the "overridden" request options.
loop {
if entry_index > n {
break;
}
let entry = &hurl_file.entries[entry_index - 1];
// We compute the new logger for this entry, before entering into the `run`
// function because entry options can modify the logger and we want the preamble
// "Executing entry..." to be displayed based on the entry level verbosity.
// We compute the new logger for this entry, before entering into the `run` function because
// entry options can modify the logger and we want the preamble "Executing entry..." to be
// displayed based on the entry level verbosity.
let logger =
get_entry_logger(entry, logger_options, &variables).map_err(|e| e.description())?;
if let Some(pre_entry) = runner_options.pre_entry {
@ -186,8 +189,8 @@ pub fn run(
} else {
false
};
// If `retry_max_reached` is true, we print now a warning, before displaying
// any assert error so any potential error is the last thing displayed to the user.
// If `retry_max_reached` is true, we print now a warning, before displaying any assert
// error so any potential error is the last thing displayed to the user.
// If `retry_max_reached` is not true (for instance `retry`is true, or there is no error
// we first log the error and a potential warning about retrying.
logger.test_erase_line();
@ -196,10 +199,30 @@ pub fn run(
logger.debug("");
}
// We logs eventual errors, only if we're not retrying the current entry...
let retry = !matches!(retry_opts, Retry::None) && !retry_max_reached && has_error;
if has_error {
log_errors(&entry_result, content, retry, &logger);
}
// When --output is overriden on a request level, we output the HTTP response only if the
// call has succeeded.
if let Ok(RunnerOptions {
output: Some(output),
..
}) = options
{
if !has_error {
// TODO: make output write error as part of entry result errors.
// For the moment, we deal the --output request failure as a simple warning and not
// an error. If we want to treat it as an error, we've to add it to the current
// `entry_result` errors, and optionally deals with retry if we can't write to the
// specified path.
if let Err(e) = entry_result.write_response(output) {
logger.warning(&e.fixme());
}
}
}
entries.push(entry_result);
if retry {
@ -209,9 +232,9 @@ pub fn run(
format!("Retry entry {entry_index} (x{retry_count} pause {delay} ms)").as_str(),
);
retry_count += 1;
// If we retry the entry, we do not want to display a 'blank' progress bar
// during the sleep delay. During the pause, we artificially show the previously
// erased progress line.
// If we retry the entry, we do not want to display a 'blank' progress bar during the
// sleep delay. During the pause, we artificially show the previously erased progress
// line.
logger.test_progress(entry_index, n);
thread::sleep(retry_interval);
logger.test_erase_line();

View File

@ -48,4 +48,5 @@ mod result;
mod runner_options;
mod template;
mod value;
mod write;
mod xpath;

View File

@ -15,7 +15,6 @@
* limitations under the License.
*
*/
use std::collections::HashMap;
use std::time::Duration;
@ -157,6 +156,9 @@ pub fn get_entry_options(
let value = eval_natural_option(value, variables)?;
runner_options.max_redirect = Some(value as usize)
}
OptionKind::Output(filename) => {
runner_options.output = Some(filename.value.clone())
}
OptionKind::PathAsIs(value) => {
let value = eval_boolean_option(value, variables)?;
runner_options.path_as_is = value

View File

@ -15,11 +15,12 @@
* limitations under the License.
*
*/
use hurl_core::ast::SourceInfo;
use hurl_core::ast::{Pos, SourceInfo};
use crate::http::{Call, Cookie};
use crate::runner::error::Error;
use crate::runner::value::Value;
use crate::runner::write::write_file;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HurlResult {
@ -60,7 +61,10 @@ pub struct EntryResult {
pub asserts: Vec<AssertResult>,
pub errors: Vec<Error>,
pub time_in_ms: u128,
pub compressed: bool, // The entry has been executed with `--compressed` option
// The entry has been executed with `--compressed` option:
// server is requested to send compressed response, and the response should be uncompressed
// when outputted on stdout.
pub compressed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -99,3 +103,32 @@ pub struct CaptureResult {
}
pub type PredicateResult = Result<(), Error>;
impl EntryResult {
/// Writes the last HTTP response of this entry result to the file `filename`.
/// The HTTP response can be decompressed if the entry's `compressed` option has been set.
pub fn write_response(&self, filename: String) -> Result<(), Error> {
match self.calls.last() {
Some(call) => {
let response = &call.response;
if self.compressed {
let bytes = match response.uncompress_body() {
Ok(bytes) => bytes,
Err(e) => {
// TODO: pass a [`SourceInfo`] in case of error
// We may pass a [`SourceInfo`] as a parameter of this method to make
// a more accurate error (for instance a [`SourceInfo`] pointing at
// `output: foo.bin`
let source_info = SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0));
return Err(Error::new(source_info, e.into(), false));
}
};
write_file(&bytes, &filename)
} else {
write_file(&response.body, &filename)
}
}
None => Ok(()),
}
}
}

View File

@ -41,6 +41,7 @@ pub struct RunnerOptionsBuilder {
ip_resolve: IpResolve,
max_redirect: Option<usize>,
no_proxy: Option<String>,
output: Option<String>,
path_as_is: bool,
post_entry: Option<fn() -> bool>,
pre_entry: Option<fn(Entry) -> bool>,
@ -77,6 +78,7 @@ impl Default for RunnerOptionsBuilder {
ip_resolve: IpResolve::default(),
max_redirect: Some(50),
no_proxy: None,
output: None,
path_as_is: false,
post_entry: None,
pre_entry: None,
@ -241,6 +243,12 @@ impl RunnerOptionsBuilder {
self
}
/// Specifies the file to output the HTTP response instead of stdout.
pub fn output(&mut self, output: Option<String>) -> &mut Self {
self.output = output;
self
}
/// Sets function to be executed after each entry execution.
///
/// If the function returns true, the run is stopped.
@ -337,6 +345,7 @@ impl RunnerOptionsBuilder {
ip_resolve: self.ip_resolve,
max_redirect: self.max_redirect,
no_proxy: self.no_proxy.clone(),
output: self.output.clone(),
path_as_is: self.path_as_is,
post_entry: self.post_entry,
pre_entry: self.pre_entry,
@ -374,6 +383,7 @@ pub struct RunnerOptions {
pub(crate) insecure: bool,
pub(crate) max_redirect: Option<usize>,
pub(crate) no_proxy: Option<String>,
pub(crate) output: Option<String>,
pub(crate) path_as_is: bool,
pub(crate) post_entry: Option<fn() -> bool>,
pub(crate) pre_entry: Option<fn(Entry) -> bool>,

View File

@ -0,0 +1,51 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use crate::runner::{Error, RunnerError};
use hurl_core::ast::{Pos, SourceInfo};
use std::fs::File;
use std::io::Write;
// TODO: make functions for sdtout
/// Writes these `bytes` to the file `filename`.
pub fn write_file(bytes: &[u8], filename: &str) -> Result<(), Error> {
let mut file = match File::create(filename) {
Ok(file) => file,
Err(e) => return Err(Error::new_file_write_access(filename, &e.to_string())),
};
match file.write_all(bytes) {
Ok(_) => Ok(()),
Err(e) => Err(Error::new_file_write_access(filename, &e.to_string())),
}
}
impl Error {
/// Creates a new file write access error.
fn new_file_write_access(filename: &str, error: &str) -> Error {
// TODO: improve the error with a [`SourcInfo`] passed in parameter.
let source_info = SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0));
Error::new(
source_info,
RunnerError::FileWriteAccess {
file: filename.to_string(),
error: error.to_string(),
},
false,
)
}
}

View File

@ -725,6 +725,7 @@ pub enum OptionKind {
IpV6(BooleanOption),
FollowLocation(BooleanOption),
MaxRedirect(NaturalOption),
Output(Filename),
PathAsIs(BooleanOption),
Proxy(Template),
Resolve(Template),
@ -755,6 +756,7 @@ impl OptionKind {
OptionKind::IpV4(_) => "ipv4",
OptionKind::IpV6(_) => "ipv6",
OptionKind::MaxRedirect(_) => "max-redirs",
OptionKind::Output(_) => "output",
OptionKind::PathAsIs(_) => "path-as-is",
OptionKind::Proxy(_) => "proxy",
OptionKind::Resolve(_) => "resolve",
@ -785,6 +787,7 @@ impl OptionKind {
OptionKind::IpV4(value) => value.to_string(),
OptionKind::IpV6(value) => value.to_string(),
OptionKind::MaxRedirect(value) => value.to_string(),
OptionKind::Output(filename) => filename.value.to_string(),
OptionKind::PathAsIs(value) => value.to_string(),
OptionKind::Proxy(value) => value.to_string(),
OptionKind::Resolve(value) => value.to_string(),

View File

@ -230,6 +230,7 @@ impl HtmlFormatter {
OptionKind::IpV4(value) => self.fmt_bool_option(value),
OptionKind::IpV6(value) => self.fmt_bool_option(value),
OptionKind::MaxRedirect(value) => self.fmt_natural_option(value),
OptionKind::Output(filename) => self.fmt_filename(filename),
OptionKind::PathAsIs(value) => self.fmt_bool_option(value),
OptionKind::Proxy(value) => self.fmt_template(value),
OptionKind::Resolve(value) => self.fmt_template(value),

View File

@ -69,6 +69,16 @@ pub enum JsonErrorVariant {
}
impl Error {
/// Creates a new error for the position `pos`, of type `inner`.
pub fn new(pos: Pos, recoverable: bool, inner: ParseError) -> Error {
Error {
pos,
recoverable,
inner,
}
}
/// Makes a recoverable error.
pub fn recoverable(&self) -> Error {
Error {
pos: self.pos,
@ -76,6 +86,8 @@ impl Error {
inner: self.inner.clone(),
}
}
/// Makes a non recoverable error.
pub fn non_recoverable(&self) -> Error {
Error {
pos: self.pos,

View File

@ -25,10 +25,11 @@ use crate::parser::reader::Reader;
use crate::parser::string::*;
use crate::parser::{expr, filename, ParseResult};
/// Parse an option in an `[Options]` section.
pub fn parse(reader: &mut Reader) -> ParseResult<EntryOption> {
let line_terminators = optional_line_terminators(reader)?;
let space0 = zero_or_more_spaces(reader)?;
let pos = reader.state.pos;
let start = reader.state.pos;
let option = reader.read_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '.');
let space1 = zero_or_more_spaces(reader)?;
try_literal(":", reader)?;
@ -50,6 +51,7 @@ pub fn parse(reader: &mut Reader) -> ParseResult<EntryOption> {
"key" => option_key(reader)?,
"location" => option_follow_location(reader)?,
"max-redirs" => option_max_redirect(reader)?,
"output" => option_output(reader)?,
"path-as-is" => option_path_as_is(reader)?,
"proxy" => option_proxy(reader)?,
"resolve" => option_resolve(reader)?,
@ -59,16 +61,10 @@ pub fn parse(reader: &mut Reader) -> ParseResult<EntryOption> {
"variable" => option_variable(reader)?,
"verbose" => option_verbose(reader)?,
"very-verbose" => option_very_verbose(reader)?,
_ => {
return Err(Error {
pos,
recoverable: true,
inner: ParseError::InvalidOption,
});
}
_ => return Err(Error::new(start, true, ParseError::InvalidOption)),
};
let line_terminator0 = line_terminator(reader)?;
let line_terminator0 = line_terminator(reader)?;
Ok(EntryOption {
line_terminators,
space0,
@ -159,6 +155,11 @@ fn option_max_redirect(reader: &mut Reader) -> ParseResult<OptionKind> {
Ok(OptionKind::MaxRedirect(value))
}
fn option_output(reader: &mut Reader) -> ParseResult<OptionKind> {
let value = filename::parse(reader)?;
Ok(OptionKind::Output(value))
}
fn option_path_as_is(reader: &mut Reader) -> ParseResult<OptionKind> {
let value = nonrecover(boolean_option, reader)?;
Ok(OptionKind::PathAsIs(value))

View File

@ -296,6 +296,7 @@ impl ToJson for EntryOption {
OptionKind::IpV4(value) => value.to_json(),
OptionKind::IpV6(value) => value.to_json(),
OptionKind::MaxRedirect(value) => JValue::Number(value.to_string()),
OptionKind::Output(filename) => JValue::String(filename.value.clone()),
OptionKind::PathAsIs(value) => value.to_json(),
OptionKind::Proxy(value) => JValue::String(value.to_string()),
OptionKind::Resolve(value) => JValue::String(value.to_string()),

View File

@ -891,6 +891,7 @@ impl Tokenizable for OptionKind {
OptionKind::IpV4(value) => value.tokenize(),
OptionKind::IpV6(value) => value.tokenize(),
OptionKind::MaxRedirect(value) => value.tokenize(),
OptionKind::Output(filename) => filename.tokenize(),
OptionKind::PathAsIs(value) => value.tokenize(),
OptionKind::Proxy(value) => value.tokenize(),
OptionKind::Resolve(value) => value.tokenize(),