diff --git a/examples/simpleish/seatrial.lua b/examples/simpleish/seatrial.lua index 9c889a8..9c0d14a 100644 --- a/examples/simpleish/seatrial.lua +++ b/examples/simpleish/seatrial.lua @@ -1,6 +1,8 @@ -- uses https://tieske.github.io/date/, a pure-Lua date library local date = require('date') +local ESOTERIC_FORMAT_REGEX = "^DAYS (%d+) SYEAR (%d+) EYEAR (%d+) SMON (%d+) EMON (%d+) SDAY (%d+) EDAY (%d+)$" + function generate_30_day_range() local today = date(true) local plus30 = today:copy():adddays(30) @@ -10,6 +12,15 @@ function generate_30_day_range() } end +function was_valid_esoteric_format(arg) + if arg.body_string:match(ESOTERIC_FORMAT_REGEX) == nil then + return ValidationResult.Error("server responded with malformed body") + end + + return ValidationResult.Ok() +end + return { generate_30_day_range = generate_30_day_range, + was_valid_esoteric_format = was_valid_esoteric_format, } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..6d2ea35 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,56 @@ +use argh::FromArgs; + +use crate::situation::SituationSpec; + +/// situational-mock-based load testing +#[derive(FromArgs)] +struct CmdArgsBase { + /// integral multiplier for grunt counts (minimum 1) + #[argh(option, short = 'm', default = "1")] + multiplier: usize, + + /// base URL for all situations in this run + #[argh(positional)] + base_url: String, + + // work around https://github.com/google/argh/issues/13 wherein repeatable positional arguments + // (situations, in this struct) allow any vec length 0+, where we require a vec length 1+. this + // could be hacked around with some From magic and a custom Vec, but this is more + // straightforward + /// path to a RON file in seatrial(5) situation config format + #[argh(positional)] + req_situation: SituationSpec, + + /// optional paths to additional RON files in seatrial(5) situation config format + #[argh(positional)] + situations: Vec, +} + +#[derive(Clone, Debug)] +pub struct CmdArgs { + /// integral multiplier for grunt counts (minimum 1) + pub multiplier: usize, + + /// base URL for all situations in this run + pub base_url: String, + + /// paths to RON files in seatrial(5) situation config format + pub situations: Vec, +} + +/// flatten situations into a single vec (see docs about CmdArgsBase::req_situation) +impl From for CmdArgs { + fn from(mut it: CmdArgsBase) -> Self { + it.situations.insert(0, it.req_situation.clone()); + + Self { + multiplier: it.multiplier, + base_url: it.base_url, + situations: it.situations, + } + } +} + +pub fn parse_args() -> CmdArgs { + argh::from_env::().into() +} diff --git a/src/config_duration.rs b/src/config_duration.rs index 8807e31..c19b8a7 100644 --- a/src/config_duration.rs +++ b/src/config_duration.rs @@ -8,30 +8,27 @@ pub enum ConfigDuration { Seconds(u64), } -impl From for Duration { - fn from(src: ConfigDuration) -> Self { - (&src).into() - } -} - impl From<&ConfigDuration> for Duration { fn from(src: &ConfigDuration) -> Self { match src { - ConfigDuration::Milliseconds(ms) => Duration::from_millis(*ms), - ConfigDuration::Seconds(ms) => Duration::from_secs(*ms), + ConfigDuration::Milliseconds(ms) => Self::from_millis(*ms), + ConfigDuration::Seconds(ms) => Self::from_secs(*ms), } } } #[test] fn test_seconds() { - assert_eq!(Duration::from_secs(10), ConfigDuration::Seconds(10).into()); + assert_eq!( + Duration::from_secs(10), + (&ConfigDuration::Seconds(10)).into() + ); } #[test] fn test_milliseconds() { assert_eq!( Duration::from_millis(100), - ConfigDuration::Milliseconds(100).into() + (&ConfigDuration::Milliseconds(100)).into() ); } diff --git a/src/http_response_table.rs b/src/http_response_table.rs index 1a9b9ab..c9f3349 100644 --- a/src/http_response_table.rs +++ b/src/http_response_table.rs @@ -99,6 +99,27 @@ impl<'a> Iterator for BoundHttpResponseTableIter<'a> { .expect("should have created headers table in registry") }), )), + 3 => Some(( + "content_type", + self.child.lua.context(|ctx| { + ctx.create_registry_value(self.child.table.content_type.clone()) + .expect("should have created content_type string in registry") + }), + )), + 4 => Some(( + "body", + self.child.lua.context(|ctx| { + ctx.create_registry_value(self.child.table.body.clone()) + .expect("should have created body table in registry") + }), + )), + 5 => Some(( + "body_string", + self.child.lua.context(|ctx| { + ctx.create_registry_value(self.child.table.body_string.clone()) + .expect("should have created body_string nilable-string in registry") + }), + )), _ => None, } } diff --git a/src/main.rs b/src/main.rs index 2e38196..abc6519 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use argh::FromArgs; use rlua::{Lua, RegistryKey}; use ureq::{Agent, AgentBuilder}; use url::Url; @@ -10,6 +9,7 @@ use std::thread; use std::thread::JoinHandle; use std::time::Duration; +mod cli; mod config_duration; mod grunt; mod http_response_table; @@ -19,18 +19,23 @@ mod pipeline; mod pipeline_action; mod shared_lua; mod situation; +mod step_combinator; mod step_error; mod step_goto; mod step_http; mod step_lua; +mod step_validator; +use crate::cli::parse_args; use crate::grunt::Grunt; use crate::persona::Persona; use crate::pipe_contents::PipeContents; use crate::pipeline::StepCompletion; use crate::pipeline_action::{ControlFlow, Http, PipelineAction as PA, Reference}; -use crate::situation::{Situation, SituationSpec}; -use crate::step_error::StepError; +use crate::shared_lua::attach_seatrial_stdlib; +use crate::situation::Situation; +use crate::step_combinator::step as do_step_combinator; +use crate::step_error::{StepError, StepResult}; use crate::step_goto::step as do_step_goto; use crate::step_http::{ step_delete as do_step_http_delete, step_get as do_step_http_get, @@ -38,36 +43,8 @@ use crate::step_http::{ }; use crate::step_lua::step_function as do_step_lua_function; -/// situational-mock-based load testing -#[derive(FromArgs)] -struct CmdArgs { - /// integral multiplier for grunt counts (minimum 1) - #[argh(option, short = 'm', default = "1")] - multiplier: usize, - - /// base URL for all situations in this run - #[argh(positional)] - base_url: String, - - // work around https://github.com/google/argh/issues/13 wherein repeatable positional arguments - // (situations, in this struct) allow any vec length 0+, where we require a vec length 1+. this - // could be hacked around with some From magic and a custom Vec, but this is more - // straightforward - /// path to a RON file in seatrial(5) situation config format - #[argh(positional)] - req_situation: SituationSpec, - - /// optional paths to additional RON files in seatrial(5) situation config format - #[argh(positional)] - situations: Vec, -} - fn main() -> std::io::Result<()> { - let args = { - let mut args: CmdArgs = argh::from_env(); - args.situations.insert(0, args.req_situation.clone()); - args - }; + let args = parse_args(); // TODO: no unwrap, which will also kill the nasty parens let base_url = (if args.base_url.ends_with('/') { @@ -135,7 +112,9 @@ fn grunt_worker( grunt: &Grunt, tx: mpsc::Sender, ) { - let lua = Lua::new(); + let lua = Lua::default(); + // TODO: no unwrap + attach_seatrial_stdlib(&lua).unwrap(); let user_script_registry_key = situation .lua_file @@ -226,8 +205,18 @@ fn grunt_worker( Ok(StepCompletion::WithWarnings { next_index, pipe_data, + warnings, }) => { - // TODO: log event for warnings + // TODO: in addition to printing, we need to track structured events (not just + // for these warnings, but for all sorts of pipeline actions) + + for warning in warnings { + eprintln!( + "[{}] warning issued during pipeline step completion: {}", + grunt.name, warning + ); + } + current_pipe_contents = pipe_data; current_pipe_idx = next_index; } @@ -243,6 +232,28 @@ fn grunt_worker( eprintln!("[{}] step was: {:?}", grunt.name, step); break; } + Err(StepError::Validation(err)) => { + eprintln!( + "[{}] aborting due to validation error in pipeline", + grunt.name + ); + eprintln!("[{}] err was: {}", grunt.name, err); + eprintln!("[{}] step was: {:?}", grunt.name, step); + break; + } + // TODO: more details - we're just not plumbing the details around + Err(StepError::ValidationSucceededUnexpectedly) => { + eprintln!( + "[{}] aborting because a validation succeeded where we expected a failure", + grunt.name + ); + eprintln!( + "[{}] this is an error in seatrial - TODO fix this", + grunt.name + ); + eprintln!("[{}] step was: {:?}", grunt.name, step); + break; + } Err(StepError::InvalidActionInContext) => { eprintln!( "[{}] aborting due to invalid action definition in the given context", @@ -328,7 +339,7 @@ fn do_step<'a>( agent: &Agent, last: Option<&PipeContents>, goto_counters: &mut HashMap, -) -> Result { +) -> StepResult { match step { PA::ControlFlow(ControlFlow::GoTo { index, max_times }) => { if let Some(times) = max_times { @@ -411,6 +422,9 @@ fn do_step<'a>( lua, ) } + PA::Combinator(combo) => { + do_step_combinator(idx, combo, lua, user_script_registry_key, last) + } // TODO: remove _ => Ok(StepCompletion::Normal { next_index: idx + 1, diff --git a/src/pipe_contents.rs b/src/pipe_contents.rs index 48e581f..a817fe9 100644 --- a/src/pipe_contents.rs +++ b/src/pipe_contents.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use crate::http_response_table::HttpResponseTable; use crate::step_error::StepError; -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum PipeContents { HttpResponse { body: Vec, diff --git a/src/pipeline.rs b/src/pipeline.rs index 0c8298e..09d4bff 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -9,6 +9,8 @@ pub enum StepCompletion { WithWarnings { next_index: usize, pipe_data: Option, + // TODO should this be a stronger type than just a string? + warnings: Vec, }, WithExit, } diff --git a/src/pipeline_action.rs b/src/pipeline_action.rs index f089c26..0a4bb63 100644 --- a/src/pipeline_action.rs +++ b/src/pipeline_action.rs @@ -124,9 +124,11 @@ pub enum Validator { // falsey, except in the context of an AnyOf or NoneOf combinator, which can "catch" the errors // as appropriate. WarnUnless validations are never fatal and likewise can never fail a // combinator + AssertHeaderEquals(String, String), AssertHeaderExists(String), AssertStatusCode(u16), AssertStatusCodeInRange(u16, u16), + WarnUnlessHeaderEquals(String, String), WarnUnlessHeaderExists(String), WarnUnlessStatusCode(u16), WarnUnlessStatusCodeInRange(u16, u16), diff --git a/src/shared_lua.rs b/src/shared_lua.rs index 757ff0b..c29e917 100644 --- a/src/shared_lua.rs +++ b/src/shared_lua.rs @@ -1,7 +1,13 @@ -use rlua::{Error as LuaError, Value as LuaValue}; +use rlua::{Error as LuaError, Lua, RegistryKey, Value as LuaValue}; +use std::rc::Rc; + +use crate::pipe_contents::PipeContents; use crate::step_error::StepError; +pub mod stdlib; +pub use stdlib::attach_seatrial_stdlib; + pub fn try_stringify_lua_value(it: Result) -> Result { match it { Ok(LuaValue::Nil) => Err(StepError::RequestedLuaValueWhereNoneExists), @@ -21,3 +27,29 @@ pub fn try_stringify_lua_value(it: Result) -> Result Err(err.into()), } } + +pub fn run_user_script_function<'a>( + fname: &str, + + // TODO: merge into a combo struct + lua: &'a Lua, + user_script_registry_key: &'a RegistryKey, + + last: Option<&'a PipeContents>, +) -> Result, StepError> { + lua.context(|ctx| { + let lua_func = ctx + .registry_value::(user_script_registry_key)? + .get::<_, rlua::Function>(fname)?; + let script_arg = match last { + Some(lval) => match lval.to_lua(lua)? { + Some(rkey) => ctx.registry_value::(&rkey)?, + None => rlua::Nil, + }, + None => rlua::Nil, + }; + let result = lua_func.call::(script_arg)?; + let registry_key = ctx.create_registry_value(result)?; + Ok(Rc::new(registry_key)) + }) +} diff --git a/src/shared_lua/stdlib/mod.rs b/src/shared_lua/stdlib/mod.rs new file mode 100644 index 0000000..ef4cbc0 --- /dev/null +++ b/src/shared_lua/stdlib/mod.rs @@ -0,0 +1,10 @@ +use rlua::{Lua, Result as LuaResult}; + +pub mod validation_result; + +pub use validation_result::{attach_validationresult, ValidationResult}; + +pub fn attach_seatrial_stdlib<'a>(lua: &'a Lua) -> LuaResult<()> { + attach_validationresult(lua)?; + Ok(()) +} diff --git a/src/shared_lua/stdlib/validation_result.rs b/src/shared_lua/stdlib/validation_result.rs new file mode 100644 index 0000000..f5cb43c --- /dev/null +++ b/src/shared_lua/stdlib/validation_result.rs @@ -0,0 +1,231 @@ +// TODO: this file's public Lua API needs documented in the seatrial.lua(3) manual or a subpage +// thereof + +use rlua::{ + Context, Error as LuaError, FromLua, Lua, Result as LuaResult, ToLua, Value as LuaValue, + Variadic, +}; + +// values stored as markers in the Lua tables that get passed around as validation result "enums", +// or a Lua approximation of the Rust concept thereof +const VALIDATION_RESULT_CODE_KEY: &'static str = "_validation_result_code"; +const VALIDATION_RESULT_WARNINGS_KEY: &'static str = "_validation_result_warnings"; +const VALIDATION_RESULT_ERROR_KEY: &'static str = "_validation_result_error"; +const VALIDATION_RESULT_OK_CODE: i8 = 1; +const VALIDATION_RESULT_OK_WITH_WARNINGS_CODE: i8 = 2; +const VALIDATION_RESULT_ERROR_CODE: i8 = 3; +const VALIDATION_RESULT_TABLE_IDX_BOUNDS: (i8, i8) = + (VALIDATION_RESULT_OK_CODE, VALIDATION_RESULT_ERROR_CODE); // keep up to date +const VALIDATION_RESULT_MISSING_WARNING_ARG_MSG: &'static str = + "expected at least one warning string, got none"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ValidationResult { + Ok, + + // TODO: rather than just shuttling strings around, is there a function in the lua VM context + // to dump a stack trace of where the warning initiated? the lua API might look as such: + // + // ```lua + // function myvalidator() + // return ValidationResult.OkWithWarnings( + // ValidationWarning("validator did nothing, which I guess is a form of being okay") + // ) + // end + // ``` + OkWithWarnings(Vec), + + // TODO: ditto from OkWithWarnings + Error(String), +} + +impl<'lua> FromLua<'lua> for ValidationResult { + fn from_lua(lval: LuaValue<'lua>, _: Context<'lua>) -> LuaResult { + match lval { + LuaValue::Table(table) => match table.get(VALIDATION_RESULT_CODE_KEY)? { + LuaValue::Number(code) => validation_result_by_i8(code as i8, table), + LuaValue::Integer(code) => validation_result_by_i8(code as i8, table), + + other => Err(LuaError::RuntimeError( + format!("expected number at table key {}, got {:?}", VALIDATION_RESULT_CODE_KEY, other) + )) + } + + other => Err(LuaError::RuntimeError( + format!("only tables (generally constructed by seatrial itself) can become ValidationResults, not {:?}", other), + )), + } + } +} + +fn validation_result_by_i8(code: i8, table: rlua::Table) -> LuaResult { + match code { + VALIDATION_RESULT_OK_CODE => Ok(ValidationResult::Ok), + + VALIDATION_RESULT_OK_WITH_WARNINGS_CODE => { + match table.get(VALIDATION_RESULT_WARNINGS_KEY)? { + LuaValue::Table(lua_warnings) => { + // I expect this will generally be called with one error, so for + // conservation of RAM, we'll allocate just enough room for one string + // for now. rust will re-allocate as necessary under the hood should + // this assumption be proven wrong, and we'll take a slight perf hit. + // whatever. + let mut warnings: Vec = Vec::with_capacity(1); + + for warning_val in lua_warnings.sequence_values::() { + warnings.push(warning_val?); + } + + if warnings.is_empty() { + return Err(LuaError::RuntimeError( + VALIDATION_RESULT_MISSING_WARNING_ARG_MSG.into(), + )); + } + + Ok(ValidationResult::OkWithWarnings(warnings)) + } + other => Err(LuaError::RuntimeError(format!( + "expected table at table key {}, got {:?}", + VALIDATION_RESULT_OK_WITH_WARNINGS_CODE, other + ))), + } + } + + VALIDATION_RESULT_ERROR_CODE => match table.get(VALIDATION_RESULT_ERROR_KEY)? { + LuaValue::String(error) => Ok(ValidationResult::Error(error.to_str()?.into())), + other => Err(LuaError::RuntimeError(format!( + "expected table at table key {}, got {:?}", + VALIDATION_RESULT_OK_WITH_WARNINGS_CODE, other + ))), + }, + + other => Err(LuaError::RuntimeError(format!( + "expected in-bounds number ({}-{}) at table key {}, got {}", + VALIDATION_RESULT_TABLE_IDX_BOUNDS.0, + VALIDATION_RESULT_TABLE_IDX_BOUNDS.1, + VALIDATION_RESULT_CODE_KEY, + other, + ))), + } +} + +impl<'lua> ToLua<'lua> for ValidationResult { + fn to_lua(self, ctx: Context<'lua>) -> LuaResult> { + let container = ctx.create_table()?; + + Ok(match self { + ValidationResult::Ok => { + container.set(VALIDATION_RESULT_CODE_KEY, VALIDATION_RESULT_OK_CODE)?; + LuaValue::Table(container) + } + + ValidationResult::OkWithWarnings(warnings) => { + container.set( + VALIDATION_RESULT_CODE_KEY, + VALIDATION_RESULT_OK_WITH_WARNINGS_CODE, + )?; + container.set(VALIDATION_RESULT_WARNINGS_KEY, warnings)?; + LuaValue::Table(container) + } + + ValidationResult::Error(err) => { + container.set(VALIDATION_RESULT_CODE_KEY, VALIDATION_RESULT_ERROR_CODE)?; + container.set(VALIDATION_RESULT_ERROR_KEY, err)?; + LuaValue::Table(container) + } + }) + } +} + +pub fn attach_validationresult<'a>(lua: &'a Lua) -> LuaResult<()> { + lua.context(|ctx| { + let globals = ctx.globals(); + + // these all return Ok because we don't actually want the lua execution context to raise an + // error, we want to get these values handed back to us as the return value of the + // validator method and we'll deal with the enum matching in rust-land + let stdlib_validationresult = ctx.create_table()?; + stdlib_validationresult + .set("Ok", ctx.create_function(|_, ()| Ok(ValidationResult::Ok))?)?; + stdlib_validationresult.set( + "OkWithWarnings", + ctx.create_function(|_, warnings: Variadic| { + Ok(ValidationResult::OkWithWarnings(warnings.to_vec())) + })?, + )?; + stdlib_validationresult.set( + "Error", + ctx.create_function(|_, err_msg: String| Ok(ValidationResult::Error(err_msg)))?, + )?; + globals.set("ValidationResult", stdlib_validationresult)?; + + Ok(()) + }) +} + +#[test] +fn test_seatrial_stdlib_validationresult_ok() -> LuaResult<()> { + let lua = Lua::default(); + attach_validationresult(&lua)?; + + lua.context(|ctx| { + Ok(assert_eq!( + ctx.load("ValidationResult.Ok()") + .eval::()?, + ValidationResult::Ok, + )) + }) +} + +#[test] +fn test_seatrial_stdlib_validationresult_ok_with_warnings() -> LuaResult<()> { + let lua = Lua::default(); + attach_validationresult(&lua)?; + + lua.context(|ctx| { + Ok(assert_eq!( + ctx.load("ValidationResult.OkWithWarnings(\"yo, this is a test!\")") + .eval::()?, + ValidationResult::OkWithWarnings(vec!["yo, this is a test!".into()]), + )) + }) +} + +#[test] +fn test_seatrial_stdlib_validationresult_ok_with_warnings_multi() -> LuaResult<()> { + let lua = Lua::default(); + attach_validationresult(&lua)?; + + lua.context(|ctx| { + Ok(assert_eq!( + ctx.load( + "ValidationResult.OkWithWarnings(\"yo, this is a test!\", \"this is also a test\")" + ) + .eval::()?, + ValidationResult::OkWithWarnings(vec![ + "yo, this is a test!".into(), + "this is also a test".into() + ]), + )) + }) +} + +#[test] +fn test_seatrial_stdlib_validationresult_ok_with_warnings_req_argument() -> LuaResult<()> { + let lua = Lua::default(); + attach_validationresult(&lua)?; + + lua.context(|ctx| { + match ctx + .load("ValidationResult.OkWithWarnings()") + .eval::() + { + Ok(result) => panic!("expected to get a RuntimeError, got {:?}", result), + Err(LuaError::RuntimeError(msg)) => { + assert_eq!(msg, VALIDATION_RESULT_MISSING_WARNING_ARG_MSG); + Ok(()) + } + Err(error) => panic!("expected to get a RuntimeError, got {:?}", error), + } + }) +} diff --git a/src/step_combinator.rs b/src/step_combinator.rs new file mode 100644 index 0000000..78585f1 --- /dev/null +++ b/src/step_combinator.rs @@ -0,0 +1,103 @@ +use rlua::{Lua, RegistryKey}; + +use crate::pipe_contents::PipeContents; +use crate::pipeline::StepCompletion; +use crate::pipeline_action::{Combinator, Validator}; +use crate::step_error::{StepError, StepResult}; +use crate::step_validator::step as do_step_validator; + +pub fn step<'a>( + idx: usize, + it: &Combinator, + + // TODO: merge into a combo struct + lua: &'a Lua, + user_script_registry_key: &'a RegistryKey, + + last: Option<&'a PipeContents>, +) -> StepResult { + match it { + Combinator::AllOf(validators) => { + all_of(idx, lua, user_script_registry_key, last, validators) + } + Combinator::AnyOf(validators) => { + any_of(idx, lua, user_script_registry_key, last, validators) + } + Combinator::NoneOf(validators) => { + match any_of(idx, lua, user_script_registry_key, last, validators) { + // TODO plumb details up the chain + Ok(_) => Err(StepError::ValidationSucceededUnexpectedly), + + // TODO should this be a NormalWithWarnings? there were failures, we just + // explicitly ignored them... + Err(_) => Ok(StepCompletion::Normal { + next_index: idx + 1, + pipe_data: None, + }), + } + } + } +} + +fn all_of<'a>( + idx: usize, + + // TODO: merge into a combo struct + lua: &'a Lua, + user_script_registry_key: &'a RegistryKey, + + last: Option<&'a PipeContents>, + + it: &Vec, +) -> StepResult { + let mut combined_warnings: Vec = Vec::with_capacity(it.len()); + + for validator in it { + match do_step_validator(idx, validator, lua, user_script_registry_key, last)? { + StepCompletion::Normal { .. } => {} + StepCompletion::WithWarnings { warnings, .. } => combined_warnings.extend(warnings), + StepCompletion::WithExit { .. } => { + unimplemented!("combinator members requesting a pipeline exit is not implemented") + } + } + } + + if combined_warnings.is_empty() { + Ok(StepCompletion::Normal { + next_index: idx + 1, + // TODO should validators put anything in the output pipe?? + pipe_data: None, + }) + } else { + Ok(StepCompletion::WithWarnings { + next_index: idx + 1, + // TODO should validators put anything in the output pipe?? + pipe_data: None, + warnings: combined_warnings, + }) + } +} + +fn any_of<'a>( + idx: usize, + + // TODO: merge into a combo struct + lua: &'a Lua, + user_script_registry_key: &'a RegistryKey, + + last: Option<&'a PipeContents>, + + it: &Vec, +) -> StepResult { + for validator in it { + if let result @ Ok(_) = + do_step_validator(idx, validator, lua, user_script_registry_key, last) + { + return result; + } + } + + Err(StepError::Validation( + "no validators in combinator succeeded".into(), + )) +} diff --git a/src/step_error.rs b/src/step_error.rs index bb2e4b5..240f8b3 100644 --- a/src/step_error.rs +++ b/src/step_error.rs @@ -2,6 +2,10 @@ use rlua::Error as LuaError; use std::io::Error as IOError; +use crate::pipeline::StepCompletion; + +pub type StepResult = Result; + #[derive(Debug)] pub enum StepError { Http(ureq::Error), @@ -15,6 +19,9 @@ pub enum StepError { Unclassified, UrlParsing(url::ParseError), + + Validation(String), + ValidationSucceededUnexpectedly, } impl From for StepError { diff --git a/src/step_goto.rs b/src/step_goto.rs index e93c731..64b6834 100644 --- a/src/step_goto.rs +++ b/src/step_goto.rs @@ -1,8 +1,8 @@ use crate::persona::Persona; use crate::pipeline::StepCompletion; -use crate::step_error::StepError; +use crate::step_error::{StepError, StepResult}; -pub fn step(desired_index: usize, persona: &Persona) -> Result { +pub fn step(desired_index: usize, persona: &Persona) -> StepResult { if desired_index > persona.spec.pipeline.len() { // TODO: provide details (expand enum to allow) return Err(StepError::Unclassified); diff --git a/src/step_http.rs b/src/step_http.rs index 4ab149e..fdfb7a0 100644 --- a/src/step_http.rs +++ b/src/step_http.rs @@ -8,7 +8,7 @@ use crate::config_duration::ConfigDuration; use crate::pipe_contents::PipeContents as PC; use crate::pipeline::StepCompletion; use crate::pipeline_action::ConfigActionMap; -use crate::step_error::StepError; +use crate::step_error::{StepError, StepResult}; #[derive(Debug)] enum Verb { @@ -29,7 +29,7 @@ pub fn step_delete( agent: &Agent, last: Option<&PC>, lua: &Lua, -) -> Result { +) -> StepResult { step( Verb::Delete, idx, @@ -54,7 +54,7 @@ pub fn step_get( agent: &Agent, last: Option<&PC>, lua: &Lua, -) -> Result { +) -> StepResult { step( Verb::Get, idx, @@ -79,7 +79,7 @@ pub fn step_head( agent: &Agent, last: Option<&PC>, lua: &Lua, -) -> Result { +) -> StepResult { step( Verb::Head, idx, @@ -104,7 +104,7 @@ pub fn step_post( agent: &Agent, last: Option<&PC>, lua: &Lua, -) -> Result { +) -> StepResult { step( Verb::Post, idx, @@ -129,7 +129,7 @@ pub fn step_put( agent: &Agent, last: Option<&PC>, lua: &Lua, -) -> Result { +) -> StepResult { step( Verb::Put, idx, @@ -155,7 +155,7 @@ fn step( agent: &Agent, last: Option<&PC>, lua: &Lua, -) -> Result { +) -> StepResult { base_url .join(path) .map_err(StepError::UrlParsing) @@ -188,7 +188,7 @@ fn request_common( params: Option<&ConfigActionMap>, last: Option<&PC>, lua: &Lua, -) -> Result { +) -> StepResult { if let Some(timeout) = timeout { req = req.timeout(timeout.into()) } diff --git a/src/step_lua.rs b/src/step_lua.rs index ad16767..2ffa072 100644 --- a/src/step_lua.rs +++ b/src/step_lua.rs @@ -1,10 +1,9 @@ use rlua::{Lua, RegistryKey}; -use std::rc::Rc; - use crate::pipe_contents::PipeContents; use crate::pipeline::StepCompletion; -use crate::step_error::StepError; +use crate::shared_lua::run_user_script_function; +use crate::step_error::StepResult; pub fn step_function<'a>( idx: usize, @@ -15,24 +14,14 @@ pub fn step_function<'a>( user_script_registry_key: &'a RegistryKey, last: Option<&'a PipeContents>, -) -> Result { - lua.context(|ctx| { - let lua_func = ctx - .registry_value::(user_script_registry_key)? - .get::<_, rlua::Function>(fname)?; - let script_arg = match last { - Some(lval) => match lval.to_lua(lua)? { - Some(rkey) => ctx.registry_value::(&rkey)?, - None => rlua::Nil, - }, - None => rlua::Nil, - }; - let result = lua_func.call::(script_arg)?; - let registry_key = ctx.create_registry_value(result)?; - - Ok(StepCompletion::Normal { - next_index: idx + 1, - pipe_data: Some(PipeContents::LuaReference(Rc::new(registry_key))), - }) +) -> StepResult { + Ok(StepCompletion::Normal { + next_index: idx + 1, + pipe_data: Some(PipeContents::LuaReference(run_user_script_function( + fname, + lua, + user_script_registry_key, + last, + )?)), }) } diff --git a/src/step_validator.rs b/src/step_validator.rs new file mode 100644 index 0000000..d46a323 --- /dev/null +++ b/src/step_validator.rs @@ -0,0 +1,244 @@ +use rlua::{Lua, RegistryKey}; + +use std::collections::HashMap; + +use crate::pipe_contents::PipeContents; +use crate::pipeline::StepCompletion; +use crate::pipeline_action::Validator; +use crate::shared_lua::run_user_script_function; +use crate::shared_lua::stdlib::ValidationResult; +use crate::step_error::{StepError, StepResult}; + +pub fn step<'a>( + idx: usize, + it: &Validator, + + // TODO: merge into a combo struct + lua: &'a Lua, + user_script_registry_key: &'a RegistryKey, + + last: Option<&'a PipeContents>, +) -> StepResult { + match (last, it) { + // TODO: this sucks as a UX, why aren't we providing any context as to WHY this was + // invalid? + (None, _) => Err(StepError::InvalidActionInContext), + + (Some(contents), Validator::AssertHeaderExists(header_name)) => { + step_assert_header_exists(idx, header_name, contents) + } + (Some(contents), Validator::WarnUnlessHeaderExists(header_name)) => { + step_warn_unless_header_exists(idx, header_name, contents) + } + + (Some(contents), Validator::AssertHeaderEquals(header_name, exp)) => { + step_assert_header_equals(idx, header_name, exp, contents) + } + (Some(contents), Validator::WarnUnlessHeaderEquals(header_name, exp)) => { + step_warn_unless_header_equals(idx, header_name, exp, contents) + } + + (Some(contents), Validator::AssertStatusCode(code)) => { + step_assert_status_code_eq(idx, *code, contents) + } + (Some(contents), Validator::WarnUnlessStatusCode(code)) => { + step_warn_unless_status_code_eq(idx, *code, contents) + } + + (Some(contents), Validator::AssertStatusCodeInRange(min_code, max_code)) => { + step_assert_status_code_in_range(idx, *min_code, *max_code, contents) + } + (Some(contents), Validator::WarnUnlessStatusCodeInRange(min_code, max_code)) => { + step_warn_unless_status_code_in_range(idx, *min_code, *max_code, contents) + } + + // TODO: should this put anything on the pipe? + (Some(contents), Validator::LuaFunction(fname)) => { + let result_rk = run_user_script_function(fname, lua, user_script_registry_key, last)?; + lua.context(|ctx| { + let validation_result: ValidationResult = ctx.registry_value(&result_rk)?; + match validation_result { + ValidationResult::Ok => Ok(StepCompletion::Normal { + next_index: idx + 1, + pipe_data: None, + }), + ValidationResult::OkWithWarnings(warnings) => { + Ok(StepCompletion::WithWarnings { + next_index: idx + 1, + pipe_data: None, + warnings: warnings, + }) + } + ValidationResult::Error(err) => Err(StepError::Validation(err)), + } + }) + } + } +} + +// TODO I've been thinking this for ages and here's where I'm finally writing it down: I'm tired of +// toting idx around. this, &'a Lua, user_script_registry_key, and various other bits I keep +// plumbing around are inherently part of the grunt's state and should be encapsulated in some +// object these methods just take mutable reference to +fn assertion_to_warning(result: StepResult, idx: usize, contents: &PipeContents) -> StepResult { + match result { + Err(StepError::Validation(error)) => Ok(StepCompletion::WithWarnings { + next_index: idx + 1, + pipe_data: Some(contents.clone()), + warnings: vec![error], + }), + other => other, + } +} + +#[derive(Debug)] +struct AssertionPredicateArgs<'a> { + body: &'a Vec, + content_type: &'a String, + headers: &'a HashMap, + status_code: u16, +} + +fn simple_assertion( + idx: usize, + contents: &PipeContents, + failure_message: String, + predicate: F, +) -> StepResult +where + F: Fn(&AssertionPredicateArgs) -> bool, +{ + match contents { + PipeContents::LuaReference(..) => Err(StepError::InvalidActionInContext), + PipeContents::HttpResponse { + body, + content_type, + headers, + status_code, + } => { + if predicate(&AssertionPredicateArgs { + body, + content_type, + headers, + status_code: *status_code, + }) { + Ok(StepCompletion::Normal { + next_index: idx + 1, + pipe_data: Some(contents.clone()), + }) + } else { + Err(StepError::Validation(failure_message)) + } + } + } +} + +#[inline] +fn normalize_header_name(name: &str) -> String { + name.trim().to_lowercase() +} + +fn step_assert_header_equals( + idx: usize, + header_name: &str, + exp: &str, + contents: &PipeContents, +) -> StepResult { + simple_assertion( + idx, + contents, + format!("response headers did not include \"{}\"", header_name), + |response| { + response + .headers + .get(&normalize_header_name(header_name)) + .map_or(false, |header_contents| header_contents == exp) + }, + ) +} + +fn step_warn_unless_header_equals( + idx: usize, + header_name: &str, + exp: &str, + contents: &PipeContents, +) -> StepResult { + assertion_to_warning( + step_assert_header_equals(idx, header_name, exp, contents), + idx, + contents, + ) +} + +fn step_assert_header_exists(idx: usize, header_name: &str, contents: &PipeContents) -> StepResult { + simple_assertion( + idx, + contents, + format!("response headers did not include \"{}\"", header_name), + |response| { + response + .headers + .contains_key(&normalize_header_name(header_name)) + }, + ) +} + +fn step_warn_unless_header_exists( + idx: usize, + header_name: &str, + contents: &PipeContents, +) -> StepResult { + assertion_to_warning( + step_assert_header_exists(idx, header_name, contents), + idx, + contents, + ) +} + +fn step_assert_status_code_in_range( + idx: usize, + + min_code: u16, + max_code: u16, + + contents: &PipeContents, +) -> StepResult { + simple_assertion( + idx, + contents, + format!("status code not in range [{}, {}]", min_code, max_code), + |response| response.status_code >= min_code && response.status_code <= max_code, + ) +} + +fn step_warn_unless_status_code_in_range( + idx: usize, + + min_code: u16, + max_code: u16, + + contents: &PipeContents, +) -> StepResult { + assertion_to_warning( + step_assert_status_code_in_range(idx, min_code, max_code, contents), + idx, + contents, + ) +} + +fn step_assert_status_code_eq(idx: usize, code: u16, contents: &PipeContents) -> StepResult { + simple_assertion( + idx, + contents, + format!("status code not equal to {}", code), + |response| response.status_code == code, + ) +} + +fn step_warn_unless_status_code_eq(idx: usize, code: u16, contents: &PipeContents) -> StepResult { + assertion_to_warning( + step_assert_status_code_eq(idx, code, contents), + idx, + contents, + ) +}