From adf3f58ae058af4fcafce9aec3d303ae03afa956 Mon Sep 17 00:00:00 2001 From: Chris Jamieson Date: Fri, 7 Aug 2026 16:01:01 +0100 Subject: [PATCH] Add functions for random data generation Add randomBool, randomEmail, randomFirstName, randomFullName, randomInt, randomLastName, randomString and randomWord template functions, using the fake crate for the lexical generators and rand for integers and strings. Function parameters are separated by whitespaces rather than enclosed in parentheses, following the style described in docs/spec/runner/expressions.md. Parameters are literals, so randomInt bounds are checked at parse time. Co-Authored-By: Claude --- README.md | 15 +- docs/samples.md | 15 +- docs/spec/grammar/hurl.grammar | 24 +++ docs/templates.md | 47 ++++- .../function_random_int_invalid_range.err | 7 + .../function_random_int_invalid_range.exit | 1 + .../function_random_int_invalid_range.hurl | 3 + .../function_random_int_invalid_range.ps1 | 3 + .../function_random_int_invalid_range.sh | 3 + .../tests_ok/function/random_function.hurl | 11 ++ .../tests_ok/function/random_function.ps1 | 4 + .../hurl/tests_ok/function/random_function.py | 23 +++ .../hurl/tests_ok/function/random_function.sh | 4 + .../hurlfmt/tests_export/random_function.html | 6 + .../hurlfmt/tests_export/random_function.hurl | 5 + .../hurlfmt/tests_export/random_function.json | 1 + .../tests_export/random_function.lint.hurl | 5 + packages/hurl/Cargo.toml | 4 + packages/hurl/README.md | 15 +- packages/hurl/src/runner/function.rs | 110 +++++++++++ packages/hurl_core/src/ast/core.rs | 15 +- packages/hurl_core/src/ast/primitive.rs | 75 ++++++- packages/hurl_core/src/parser/function.rs | 185 +++++++++++++++++- 23 files changed, 570 insertions(+), 11 deletions(-) create mode 100644 integration/hurl/tests_error_parser/function_random_int_invalid_range.err create mode 100644 integration/hurl/tests_error_parser/function_random_int_invalid_range.exit create mode 100644 integration/hurl/tests_error_parser/function_random_int_invalid_range.hurl create mode 100644 integration/hurl/tests_error_parser/function_random_int_invalid_range.ps1 create mode 100755 integration/hurl/tests_error_parser/function_random_int_invalid_range.sh create mode 100644 integration/hurl/tests_ok/function/random_function.hurl create mode 100644 integration/hurl/tests_ok/function/random_function.ps1 create mode 100644 integration/hurl/tests_ok/function/random_function.py create mode 100755 integration/hurl/tests_ok/function/random_function.sh create mode 100644 integration/hurlfmt/tests_export/random_function.html create mode 100644 integration/hurlfmt/tests_export/random_function.hurl create mode 100644 integration/hurlfmt/tests_export/random_function.json create mode 100644 integration/hurlfmt/tests_export/random_function.lint.hurl diff --git a/README.md b/README.md index 76b9fc87b29..f731add3472 100644 --- a/README.md +++ b/README.md @@ -604,7 +604,7 @@ GraphQL queries can also use [Hurl templates]. ### Using Dynamic Datas -[Functions] like `newUuid` and `newDate` can be used in templates to create dynamic datas: +[Functions] like `newUuid`, `newDate` or `randomFirstName` can be used in templates to create dynamic datas: A file that creates a dynamic email (i.e `0531f78f-7f87-44be-a7f2-969a1c4e6d97@test.com`): @@ -626,6 +626,19 @@ date: {{newDate}} HTTP 200 ``` +A file that creates a random user. Functions parameters are separated with a whitespace, like filters parameters: + +```hurl +POST https://example.org/api/users +{ + "first_name": "{{randomFirstName}}", + "last_name": "{{randomLastName}}", + "email": "{{randomEmail}}", + "age": {{randomInt 18 99}}, + "token": "{{randomString 32}}" +} +``` + [Doc](https://hurl.dev/docs/templates.html#functions) ## Testing Response diff --git a/docs/samples.md b/docs/samples.md index 688d3c84188..62ddc8f523d 100644 --- a/docs/samples.md +++ b/docs/samples.md @@ -324,7 +324,7 @@ GraphQL queries can also use [Hurl templates]. ### Using Dynamic Datas -[Functions] like `newUuid` and `newDate` can be used in templates to create dynamic datas: +[Functions] like `newUuid`, `newDate` or `randomFirstName` can be used in templates to create dynamic datas: A file that creates a dynamic email (i.e `0531f78f-7f87-44be-a7f2-969a1c4e6d97@test.com`): @@ -346,6 +346,19 @@ date: {{newDate}} HTTP 200 ``` +A file that creates a random user. Functions parameters are separated with a whitespace, like filters parameters: + +```hurl +POST https://example.org/api/users +{ + "first_name": "{{randomFirstName}}", + "last_name": "{{randomLastName}}", + "email": "{{randomEmail}}", + "age": {{randomInt 18 99}}, + "token": "{{randomString 32}}" +} +``` + [Doc](/docs/templates.md#functions) ## Testing Response diff --git a/docs/spec/grammar/hurl.grammar b/docs/spec/grammar/hurl.grammar index 0773e849151..61534cfef3d 100644 --- a/docs/spec/grammar/hurl.grammar +++ b/docs/spec/grammar/hurl.grammar @@ -560,6 +560,14 @@ function: env-function | now-function | uuid-function + | random-bool-function + | random-email-function + | random-first-name-function + | random-full-name-function + | random-int-function + | random-last-name-function + | random-string-function + | random-word-function env-function: "getEnv" @@ -567,6 +575,22 @@ now-function: "newDate" uuid-function: "newUuid" +random-bool-function: "randomBool" + +random-email-function: "randomEmail" + +random-first-name-function: "randomFirstName" + +random-full-name-function: "randomFullName" + +random-int-function: "randomInt" sp integer sp integer + +random-last-name-function: "randomLastName" + +random-string-function: "randomString" sp integer + +random-word-function: "randomWord" + # Filter diff --git a/docs/templates.md b/docs/templates.md index bf3cbf8337d..38c780cd136 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -41,10 +41,20 @@ jsonpath "$.errors[{{index}}].id" == "error" Besides variables, functions can be used to generate dynamic values. Current functions are: -| Function | Description | -|-----------|--------------------------------------------------------------| -| `newUuid` | Generates an [UUID v4 random string] | -| `newDate` | Generates an [RFC 3339] UTC date string, at the current time | +| Function | Description | +|-------------------------|-------------------------------------------------------------------| +| `newUuid` | Generates an [UUID v4 random string] | +| `newDate` | Generates an [RFC 3339] UTC date string, at the current time | +| `randomBool` | Generates a random boolean | +| `randomEmail` | Generates a random email address | +| `randomFirstName` | Generates a random first name | +| `randomFullName` | Generates a random full name | +| `randomInt ` | Generates a random integer between `min` and `max`, both included | +| `randomLastName` | Generates a random last name | +| `randomString ` | Generates a random alphanumeric string of `count` characters | +| `randomWord` | Generates a random word | + +Like filters, functions parameters are not enclosed in parentheses and are separated with a whitespace. In the following example, we use `newDate` to generate a dynamic query parameter: @@ -77,6 +87,35 @@ When run, the request body will be: } ``` +The `random...` functions generate realistic test data. In this third example, we create a user with a random +name, email and age: + +```hurl +POST https://example.org/api/users +{ + "first_name": "{{randomFirstName}}", + "last_name": "{{randomLastName}}", + "email": "{{randomEmail}}", + "age": {{randomInt 18 99}}, + "token": "{{randomString 32}}" +} +``` + +When run, the request body will be: + +``` +{ + "first_name": "Vernice", + "last_name": "Watsica", + "email": "kaley.bins@example.com", + "age": 42, + "token": "kZ3xQpL8mNvT2wYc7RbA5sHdEjFgU9iO" +} +``` + +Note that `randomInt` returns a number and `randomBool` a boolean: used alone in a JSON body, they don't need to be +quoted. + ## Types diff --git a/integration/hurl/tests_error_parser/function_random_int_invalid_range.err b/integration/hurl/tests_error_parser/function_random_int_invalid_range.err new file mode 100644 index 00000000000..03e1dc26106 --- /dev/null +++ b/integration/hurl/tests_error_parser/function_random_int_invalid_range.err @@ -0,0 +1,7 @@ +error: Parsing literal + --> tests_error_parser/function_random_int_invalid_range.hurl:3:24 + | + 3 | value: {{randomInt 100 1}} + | ^ expecting 'an integer greater than or equal to 100' + | + diff --git a/integration/hurl/tests_error_parser/function_random_int_invalid_range.exit b/integration/hurl/tests_error_parser/function_random_int_invalid_range.exit new file mode 100644 index 00000000000..0cfbf08886f --- /dev/null +++ b/integration/hurl/tests_error_parser/function_random_int_invalid_range.exit @@ -0,0 +1 @@ +2 diff --git a/integration/hurl/tests_error_parser/function_random_int_invalid_range.hurl b/integration/hurl/tests_error_parser/function_random_int_invalid_range.hurl new file mode 100644 index 00000000000..cefd7025950 --- /dev/null +++ b/integration/hurl/tests_error_parser/function_random_int_invalid_range.hurl @@ -0,0 +1,3 @@ +GET http://localhost:8000 +[Query] +value: {{randomInt 100 1}} diff --git a/integration/hurl/tests_error_parser/function_random_int_invalid_range.ps1 b/integration/hurl/tests_error_parser/function_random_int_invalid_range.ps1 new file mode 100644 index 00000000000..7370df288aa --- /dev/null +++ b/integration/hurl/tests_error_parser/function_random_int_invalid_range.ps1 @@ -0,0 +1,3 @@ +Set-StrictMode -Version latest +$ErrorActionPreference = 'Stop' +hurl tests_error_parser/function_random_int_invalid_range.hurl diff --git a/integration/hurl/tests_error_parser/function_random_int_invalid_range.sh b/integration/hurl/tests_error_parser/function_random_int_invalid_range.sh new file mode 100755 index 00000000000..3260fc868c0 --- /dev/null +++ b/integration/hurl/tests_error_parser/function_random_int_invalid_range.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -Eeuo pipefail +hurl tests_error_parser/function_random_int_invalid_range.hurl diff --git a/integration/hurl/tests_ok/function/random_function.hurl b/integration/hurl/tests_ok/function/random_function.hurl new file mode 100644 index 00000000000..66387de7d54 --- /dev/null +++ b/integration/hurl/tests_ok/function/random_function.hurl @@ -0,0 +1,11 @@ +GET http://localhost:8000/random-function +[Query] +bool: {{randomBool}} +email: {{randomEmail}} +first-name: {{randomFirstName}} +full-name: {{randomFullName}} +int: {{randomInt 10 99}} +last-name: {{randomLastName}} +string: {{randomString 32}} +word: {{randomWord}} +HTTP 200 diff --git a/integration/hurl/tests_ok/function/random_function.ps1 b/integration/hurl/tests_ok/function/random_function.ps1 new file mode 100644 index 00000000000..6c60f92d4bb --- /dev/null +++ b/integration/hurl/tests_ok/function/random_function.ps1 @@ -0,0 +1,4 @@ +Set-StrictMode -Version latest +$ErrorActionPreference = 'Stop' + +hurl tests_ok/function/random_function.hurl diff --git a/integration/hurl/tests_ok/function/random_function.py b/integration/hurl/tests_ok/function/random_function.py new file mode 100644 index 00000000000..edcbf3067ef --- /dev/null +++ b/integration/hurl/tests_ok/function/random_function.py @@ -0,0 +1,23 @@ +import re + +from app import app +from flask import request + + +@app.route("/random-function") +def random_function(): + assert request.args.get("bool") in ["true", "false"] + + email = request.args.get("email") + assert re.match(r"^[^@\s]+@[^@\s]+$", email) + + value = int(request.args.get("int")) + assert 10 <= value <= 99 + + string = request.args.get("string") + assert re.match(r"^[A-Za-z0-9]{32}$", string) + + for name in ["first-name", "last-name", "full-name", "word"]: + assert len(request.args.get(name)) > 0 + + return "" diff --git a/integration/hurl/tests_ok/function/random_function.sh b/integration/hurl/tests_ok/function/random_function.sh new file mode 100755 index 00000000000..598707dece4 --- /dev/null +++ b/integration/hurl/tests_ok/function/random_function.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -Eeuo pipefail + +hurl tests_ok/function/random_function.hurl diff --git a/integration/hurlfmt/tests_export/random_function.html b/integration/hurlfmt/tests_export/random_function.html new file mode 100644 index 00000000000..3be8ce763e4 --- /dev/null +++ b/integration/hurlfmt/tests_export/random_function.html @@ -0,0 +1,6 @@ +
GET http://localhost:8000/hello
+[Query]
+int: {{randomInt 10 99}}
+string: {{randomString 32}}
+name: {{randomFirstName}}
+
diff --git a/integration/hurlfmt/tests_export/random_function.hurl b/integration/hurlfmt/tests_export/random_function.hurl new file mode 100644 index 00000000000..cca874ddad2 --- /dev/null +++ b/integration/hurlfmt/tests_export/random_function.hurl @@ -0,0 +1,5 @@ +GET http://localhost:8000/hello +[Query] +int: {{randomInt 10 99}} +string: {{randomString 32}} +name: {{randomFirstName}} diff --git a/integration/hurlfmt/tests_export/random_function.json b/integration/hurlfmt/tests_export/random_function.json new file mode 100644 index 00000000000..4f93750bfaf --- /dev/null +++ b/integration/hurlfmt/tests_export/random_function.json @@ -0,0 +1 @@ +{"entries":[{"request":{"method":"GET","url":"http://localhost:8000/hello","query_string_params":[{"name":"int","value":"{{randomInt 10 99}}"},{"name":"string","value":"{{randomString 32}}"},{"name":"name","value":"{{randomFirstName}}"}]}}]} diff --git a/integration/hurlfmt/tests_export/random_function.lint.hurl b/integration/hurlfmt/tests_export/random_function.lint.hurl new file mode 100644 index 00000000000..cca874ddad2 --- /dev/null +++ b/integration/hurlfmt/tests_export/random_function.lint.hurl @@ -0,0 +1,5 @@ +GET http://localhost:8000/hello +[Query] +int: {{randomInt 10 99}} +string: {{randomString 32}} +name: {{randomFirstName}} diff --git a/packages/hurl/Cargo.toml b/packages/hurl/Cargo.toml index 8ee4722de89..882b9df3a84 100644 --- a/packages/hurl/Cargo.toml +++ b/packages/hurl/Cargo.toml @@ -26,12 +26,16 @@ clap = { version = "4.6.5", features = ["string", "wrap_help"] } curl = "0.4.50" curl-sys = "0.4.90" encoding_rs = "0.8.35" +# fake generates the random data of the random... functions. Default features are disabled to keep +# the dependency tree minimal; its rand requirement is already satisfied by the version used by uuid. +fake = { version = "5.1.0", default-features = false } glob = "0.3.4" hurl_core = { version = "8.1.0", path = "../hurl_core" } libflate = "2.3.1" libxml = "0.3.21" md5 = "0.8.1" percent-encoding = "2.3.2" +rand = "0.10.2" regex = "1.13.1" serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.151", features = ["arbitrary_precision"] } diff --git a/packages/hurl/README.md b/packages/hurl/README.md index c6764dc1716..4374e87be6a 100644 --- a/packages/hurl/README.md +++ b/packages/hurl/README.md @@ -604,7 +604,7 @@ GraphQL queries can also use [Hurl templates]. ### Using Dynamic Datas -[Functions] like `newUuid` and `newDate` can be used in templates to create dynamic datas: +[Functions] like `newUuid`, `newDate` or `randomFirstName` can be used in templates to create dynamic datas: A file that creates a dynamic email (i.e `0531f78f-7f87-44be-a7f2-969a1c4e6d97@test.com`): @@ -626,6 +626,19 @@ date: {{newDate}} HTTP 200 ``` +A file that creates a random user. Functions parameters are separated with a whitespace, like filters parameters: + +```hurl +POST https://example.org/api/users +{ + "first_name": "{{randomFirstName}}", + "last_name": "{{randomLastName}}", + "email": "{{randomEmail}}", + "age": {{randomInt 18 99}}, + "token": "{{randomString 32}}" +} +``` + [Doc](https://hurl.dev/docs/templates.html#functions) ## Testing Response diff --git a/packages/hurl/src/runner/function.rs b/packages/hurl/src/runner/function.rs index 2a361aff6a4..d9a57f08a27 100644 --- a/packages/hurl/src/runner/function.rs +++ b/packages/hurl/src/runner/function.rs @@ -16,12 +16,20 @@ * */ use chrono::Utc; +use fake::Fake; +use fake::faker::internet::en::SafeEmail; +use fake::faker::lorem::en::Word; +use fake::faker::name::en::{FirstName, LastName, Name}; use hurl_core::ast::Function; use uuid::Uuid; use super::error::RunnerError; +use super::number::Number; use super::value::Value; +/// Alphabet used by the `randomString` function. +const ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + /// Evaluates the function `function`, returns a [`Value`] on success or an [`RunnerError`] . pub fn eval(function: &Function) -> Result { match &function { @@ -33,5 +41,107 @@ pub fn eval(function: &Function) -> Result { let uuid = Uuid::new_v4(); Ok(Value::String(uuid.to_string())) } + Function::RandomBool => Ok(Value::Bool(rand::random::())), + Function::RandomEmail => Ok(Value::String(SafeEmail().fake())), + Function::RandomFirstName => Ok(Value::String(FirstName().fake())), + Function::RandomFullName => Ok(Value::String(Name().fake())), + Function::RandomInt(args) => { + // The parser has already rejected `min` greater than `max`, so the range is never + // empty and `random_range` can not panic. + let value = rand::random_range(args.min.as_i64()..=args.max.as_i64()); + Ok(Value::Number(Number::Integer(value))) + } + Function::RandomLastName => Ok(Value::String(LastName().fake())), + Function::RandomString(args) => { + let value = (0..args.count.as_u64()) + .map(|_| ALPHANUMERIC[rand::random_range(0..ALPHANUMERIC.len())] as char) + .collect::(); + Ok(Value::String(value)) + } + Function::RandomWord => Ok(Value::String(Word().fake())), + } +} + +#[cfg(test)] +mod tests { + use hurl_core::ast::{I64, RandomIntArgs, RandomStringArgs, SourceInfo, U64, Whitespace}; + use hurl_core::reader::Pos; + use hurl_core::types::ToSource; + + use super::*; + + fn whitespace() -> Whitespace { + Whitespace { + value: " ".to_string(), + source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), + } + } + + #[test] + fn eval_random_int_is_within_bounds() { + let function = Function::RandomInt(Box::new(RandomIntArgs { + space0: whitespace(), + min: I64::new(1, "1".to_source()), + space1: whitespace(), + max: I64::new(10, "10".to_source()), + })); + for _ in 0..100 { + let Value::Number(Number::Integer(value)) = eval(&function).unwrap() else { + panic!("randomInt should return an integer"); + }; + assert!((1..=10).contains(&value)); + } + } + + #[test] + fn eval_random_int_accepts_negative_bounds() { + let function = Function::RandomInt(Box::new(RandomIntArgs { + space0: whitespace(), + min: I64::new(-5, "-5".to_source()), + space1: whitespace(), + max: I64::new(-5, "-5".to_source()), + })); + let Value::Number(Number::Integer(value)) = eval(&function).unwrap() else { + panic!("randomInt should return an integer"); + }; + assert_eq!(value, -5); + } + + #[test] + fn eval_random_string_has_requested_length() { + for count in [0, 1, 32] { + let function = Function::RandomString(Box::new(RandomStringArgs { + space0: whitespace(), + count: U64::new(count, count.to_string().to_source()), + })); + let Value::String(value) = eval(&function).unwrap() else { + panic!("randomString should return a string"); + }; + assert_eq!(value.chars().count(), count as usize); + assert!(value.chars().all(|c| c.is_ascii_alphanumeric())); + } + } + + #[test] + fn eval_random_email_looks_like_an_email() { + let Value::String(value) = eval(&Function::RandomEmail).unwrap() else { + panic!("randomEmail should return a string"); + }; + assert_eq!(value.matches('@').count(), 1); + } + + #[test] + fn eval_random_names_are_not_empty() { + for function in [ + Function::RandomFirstName, + Function::RandomLastName, + Function::RandomFullName, + Function::RandomWord, + ] { + let Value::String(value) = eval(&function).unwrap() else { + panic!("{function} should return a string"); + }; + assert!(!value.is_empty()); + } } } diff --git a/packages/hurl_core/src/ast/core.rs b/packages/hurl_core/src/ast/core.rs index ec923fe5b07..966334c5534 100644 --- a/packages/hurl_core/src/ast/core.rs +++ b/packages/hurl_core/src/ast/core.rs @@ -263,7 +263,20 @@ pub struct Body { /// Check that variable name is not reserved /// (would conflicts with an existing function) pub fn is_variable_reserved(name: &str) -> bool { - ["getEnv", "newDate", "newUuid"].contains(&name) + [ + "getEnv", + "newDate", + "newUuid", + "randomBool", + "randomEmail", + "randomFirstName", + "randomFullName", + "randomInt", + "randomLastName", + "randomString", + "randomWord", + ] + .contains(&name) } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/packages/hurl_core/src/ast/primitive.rs b/packages/hurl_core/src/ast/primitive.rs index 0b3e7faa2a3..e812e165993 100644 --- a/packages/hurl_core/src/ast/primitive.rs +++ b/packages/hurl_core/src/ast/primitive.rs @@ -542,17 +542,88 @@ impl fmt::Display for Variable { } } +/// Parameters of the `randomInt` function. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RandomIntArgs { + pub space0: Whitespace, + pub min: I64, + pub space1: Whitespace, + pub max: I64, +} + +/// Parameters of the `randomString` function. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RandomStringArgs { + pub space0: Whitespace, + pub count: U64, +} + +/// A generator function, used inside a placeholder. +/// +/// Parameters are separated by whitespaces, following the Hurl style for filters: +/// `{{randomInt 1 100}}`. Whitespaces are kept in the AST so that a parsed file can be +/// rendered back to its exact source. +/// +/// Parameters are boxed to keep this enum small: [`Function`] ends up in [`Placeholder`], which is +/// itself a variant of several enums that would otherwise trip `clippy::large_enum_variant`. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Function { NewDate, NewUuid, + RandomBool, + RandomEmail, + RandomFirstName, + RandomFullName, + RandomInt(Box), + RandomLastName, + RandomString(Box), + RandomWord, +} + +impl Function { + /// Returns the identifier of this function, without any parameter. + pub fn identifier(&self) -> &'static str { + match self { + Function::NewDate => "newDate", + Function::NewUuid => "newUuid", + Function::RandomBool => "randomBool", + Function::RandomEmail => "randomEmail", + Function::RandomFirstName => "randomFirstName", + Function::RandomFullName => "randomFullName", + Function::RandomInt(_) => "randomInt", + Function::RandomLastName => "randomLastName", + Function::RandomString(_) => "randomString", + Function::RandomWord => "randomWord", + } + } } impl fmt::Display for Function { fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.identifier())?; match self { - Function::NewDate => write!(f, "newDate"), - Function::NewUuid => write!(f, "newUuid"), + Function::RandomInt(args) => write!( + f, + "{}{}{}{}", + args.space0.as_str(), + args.min.to_source(), + args.space1.as_str(), + args.max.to_source() + ), + Function::RandomString(args) => { + write!(f, "{}{}", args.space0.as_str(), args.count.to_source()) + } + // Listed explicitly rather than with a catch-all: this `Display` is what + // `ToSource for Expr` uses, so a new function with parameters must not silently + // render without them. + Function::NewDate + | Function::NewUuid + | Function::RandomBool + | Function::RandomEmail + | Function::RandomFirstName + | Function::RandomFullName + | Function::RandomLastName + | Function::RandomWord => Ok(()), } } } diff --git a/packages/hurl_core/src/parser/function.rs b/packages/hurl_core/src/parser/function.rs index afd736e1c49..02da79620e1 100644 --- a/packages/hurl_core/src/parser/function.rs +++ b/packages/hurl_core/src/parser/function.rs @@ -15,18 +15,57 @@ * limitations under the License. * */ -use crate::ast::Function; +use crate::ast::{Function, I64, RandomIntArgs, RandomStringArgs, U64, Whitespace}; +use crate::parser::number::{integer, natural}; +use crate::parser::primitives::one_or_more_spaces; use crate::parser::{ParseError, ParseErrorKind, ParseResult}; use crate::reader::Reader; /// Parse a function /// +/// Functions parameters are separated by whitespaces, like filters parameters: +/// `randomInt 1 100`. pub fn parse(reader: &mut Reader) -> ParseResult { let start = reader.cursor(); let function_name = reader.read_while(|c| c.is_alphanumeric() || c == '_' || c == '-'); match function_name.as_str() { "newDate" => Ok(Function::NewDate), "newUuid" => Ok(Function::NewUuid), + "randomBool" => Ok(Function::RandomBool), + "randomEmail" => Ok(Function::RandomEmail), + "randomFirstName" => Ok(Function::RandomFirstName), + "randomFullName" => Ok(Function::RandomFullName), + "randomInt" => { + let space0 = space_param(reader)?; + let min = integer_param(reader)?; + let space1 = space_param(reader)?; + let start_max = reader.cursor(); + let max = integer_param(reader)?; + // Both bounds are literals, so an empty range can be rejected right away rather than + // at run time. + if min.as_i64() > max.as_i64() { + let kind = ParseErrorKind::Expecting { + value: format!("an integer greater than or equal to {min}"), + }; + return Err(ParseError::new(start_max.pos, false, kind)); + } + Ok(Function::RandomInt(Box::new(RandomIntArgs { + space0, + min, + space1, + max, + }))) + } + "randomLastName" => Ok(Function::RandomLastName), + "randomString" => { + let space0 = space_param(reader)?; + let count = natural_param(reader)?; + Ok(Function::RandomString(Box::new(RandomStringArgs { + space0, + count, + }))) + } + "randomWord" => Ok(Function::RandomWord), _ => Err(ParseError::new( start.pos, true, @@ -37,10 +76,45 @@ pub fn parse(reader: &mut Reader) -> ParseResult { } } +/// Parses the whitespace preceding a function parameter. +/// +/// The function name has already been consumed at this point, so the error is not recoverable: +/// we don't want the caller to backtrack and parse the function name as a variable. +fn space_param(reader: &mut Reader) -> ParseResult { + one_or_more_spaces(reader).map_err(|e| ParseError::new(e.pos, false, e.kind)) +} + +/// Parses an integer function parameter. +/// +/// As [`space_param`], the error is not recoverable. +fn integer_param(reader: &mut Reader) -> ParseResult { + integer(reader).map_err(|e| { + let kind = ParseErrorKind::Expecting { + value: "integer".to_string(), + }; + ParseError::new(e.pos, false, kind) + }) +} + +/// Parses a natural function parameter. +/// +/// As [`space_param`], the error is not recoverable. Using a natural rather than an integer means +/// that negative values are rejected by the number parser itself. +fn natural_param(reader: &mut Reader) -> ParseResult { + natural(reader).map_err(|e| { + let kind = ParseErrorKind::Expecting { + value: "natural".to_string(), + }; + ParseError::new(e.pos, false, kind) + }) +} + #[cfg(test)] mod tests { use super::*; + use crate::ast::SourceInfo; use crate::reader::Pos; + use crate::types::ToSource; #[test] fn test_exist() { @@ -55,4 +129,113 @@ mod tests { assert_eq!(err.pos, Pos::new(1, 1)); assert!(err.recoverable); } + + #[test] + fn test_random_no_param() { + let mut reader = Reader::new("randomFirstName"); + assert_eq!(parse(&mut reader).unwrap(), Function::RandomFirstName); + + let mut reader = Reader::new("randomBool"); + assert_eq!(parse(&mut reader).unwrap(), Function::RandomBool); + } + + #[test] + fn test_random_int() { + let mut reader = Reader::new("randomInt 1 100"); + let function = parse(&mut reader).unwrap(); + assert_eq!( + function, + Function::RandomInt(Box::new(RandomIntArgs { + space0: Whitespace { + value: " ".to_string(), + source_info: SourceInfo::new(Pos::new(1, 10), Pos::new(1, 11)), + }, + min: I64::new(1, "1".to_source()), + space1: Whitespace { + value: " ".to_string(), + source_info: SourceInfo::new(Pos::new(1, 12), Pos::new(1, 13)), + }, + max: I64::new(100, "100".to_source()), + })) + ); + } + + #[test] + fn test_random_int_negative() { + let mut reader = Reader::new("randomInt -10 -1"); + let function = parse(&mut reader).unwrap(); + assert_eq!(function.to_string(), "randomInt -10 -1"); + } + + #[test] + fn test_random_string() { + let mut reader = Reader::new("randomString 10"); + let function = parse(&mut reader).unwrap(); + assert_eq!(function.to_string(), "randomString 10"); + } + + /// Extra whitespaces between parameters are kept, so that a file can be rendered back to its + /// exact source. + #[test] + fn test_random_int_keeps_spaces() { + let mut reader = Reader::new("randomInt 1 100"); + let function = parse(&mut reader).unwrap(); + assert_eq!(function.to_string(), "randomInt 1 100"); + } + + /// Once the function name has been read, a malformed parameter is not recoverable: the caller + /// must not fall back on parsing `randomInt` as a variable name. + #[test] + fn test_random_int_missing_param() { + let mut reader = Reader::new("randomInt 1"); + let err = parse(&mut reader).unwrap_err(); + assert!(!err.recoverable); + + let mut reader = Reader::new("randomInt"); + let err = parse(&mut reader).unwrap_err(); + assert!(!err.recoverable); + + let mut reader = Reader::new("randomInt a b"); + let err = parse(&mut reader).unwrap_err(); + assert!(!err.recoverable); + assert_eq!( + err.kind, + ParseErrorKind::Expecting { + value: "integer".to_string() + } + ); + } + + /// Both bounds are literals, so an empty range is a parse error, not a run time one. + #[test] + fn test_random_int_empty_range() { + let mut reader = Reader::new("randomInt 100 1"); + let err = parse(&mut reader).unwrap_err(); + assert!(!err.recoverable); + assert_eq!(err.pos, Pos::new(1, 15)); + assert_eq!( + err.kind, + ParseErrorKind::Expecting { + value: "an integer greater than or equal to 100".to_string() + } + ); + + // A range of exactly one value is valid. + let mut reader = Reader::new("randomInt 5 5"); + assert!(parse(&mut reader).is_ok()); + } + + /// `randomString` takes a natural, so a negative count cannot be expressed. + #[test] + fn test_random_string_negative_count() { + let mut reader = Reader::new("randomString -1"); + let err = parse(&mut reader).unwrap_err(); + assert!(!err.recoverable); + assert_eq!( + err.kind, + ParseErrorKind::Expecting { + value: "natural".to_string() + } + ); + } }