Skip to content

Commit

Permalink
Support dynamic options in !select chains
Browse files Browse the repository at this point in the history
Option list can be generated dynamically. The rendered source template will be parsed as JSON, and can optionally have an additional JSONPath selector applied.

Still needs docs and deserialization improvements, to come later.
  • Loading branch information
anussel5559 authored and LucasPickering committed Sep 8, 2024
1 parent 08fc8ba commit e4d61bb
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 39 deletions.
17 changes: 16 additions & 1 deletion crates/slumber_core/src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,22 @@ pub enum ChainSource {
/// Descriptor to show to the user
message: Option<Template>,
/// List of options to choose from
options: Vec<Template>,
options: SelectOptions,
},
}

/// Static or dynamic list of options for a select chain
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
#[serde(rename_all = "snake_case", deny_unknown_fields, untagged)]
pub enum SelectOptions {
Fixed(Vec<Template>),
/// Dynamic requires a source (often a chain) that either returns a JSON
/// array OR a JSON object, in which case we'll use selector to query
/// and find the array to parse in to the list of options
Dynamic {
source: Template,
selector: Option<Query>,
},
}

Expand Down
9 changes: 7 additions & 2 deletions crates/slumber_core/src/http/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::http::content_type::ResponseContent;
use derive_more::{Display, FromStr};
use serde::{Deserialize, Serialize};
use serde_json_path::{ExactlyOneError, JsonPath};
use serde_json_path::{ExactlyOneError, JsonPath, NodeList};
use std::borrow::Cow;
use thiserror::Error;

Expand All @@ -17,7 +17,7 @@ impl Query {
/// Apply a query to some content, returning the result in the original
/// format. This will convert to a common format, apply the query, then
/// convert back.
pub fn query(
pub fn query_content(
&self,
value: &dyn ResponseContent,
) -> Box<dyn ResponseContent> {
Expand All @@ -30,6 +30,11 @@ impl Query {
content_type.parse_json(Cow::Owned(queried))
}

/// Apply a query to some content, returning a list of results.
pub fn query_list<'a>(&self, value: &'a serde_json::Value) -> NodeList<'a> {
self.0.query(value)
}

/// Apply a query to some content, returning a string. The query should
/// return a single result. If it's a scalar, that will be stringified. If
/// it's an array/object, it'll be converted back into its input format,
Expand Down
164 changes: 151 additions & 13 deletions crates/slumber_core/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ mod tests {
assert_err,
collection::{
Chain, ChainOutputTrim, ChainRequestSection, ChainRequestTrigger,
ChainSource, Profile, Recipe, RecipeId,
ChainSource, Profile, Recipe, RecipeId, SelectOptions,
},
http::{
content_type::ContentType, Exchange, RequestRecord, ResponseRecord,
Expand All @@ -276,7 +276,7 @@ mod tests {
use chrono::Utc;
use indexmap::indexmap;
use rstest::rstest;
use serde_json::json;
use serde_json::{json, Value};
use std::time::Duration;
use tokio::fs;
use wiremock::{matchers, Mock, MockServer, ResponseTemplate};
Expand Down Expand Up @@ -947,24 +947,20 @@ mod tests {
}

#[rstest]
#[case::no_chains(vec!["foo!", "bar!"], 0, "foo!")]
#[case::chains_0(vec!["foo!", "{{chains.command}}"], 0, "foo!")]
#[case::chains_1(vec!["foo!", "{{chains.command}}"], 1, "command_output")]
#[case::no_chains(SelectOptions::Fixed(vec!["foo!".into(), "bar!".into()]), 0, "foo!")]
#[case::chains_0(SelectOptions::Fixed(vec!["foo!".into(), "{{chains.command}}".into()]), 0, "foo!")]
#[case::chains_1(SelectOptions::Fixed(vec!["foo!".into(), "{{chains.command}}".into()]), 1, "command_output")]
#[tokio::test]
async fn test_chain_select(
#[case] options: Vec<&str>,
async fn test_chain_fixed_select(
#[case] options: SelectOptions,
#[case] index: usize,
#[case] expected: &str,
) {
let sut_chain = Chain {
id: "sut".into(),
source: ChainSource::Select {
message: Some("password".into()),
options: options
.clone()
.into_iter()
.map(|s| s.into())
.collect(),
options,
},
..Chain::factory(())
};
Expand All @@ -989,12 +985,109 @@ mod tests {
assert_eq!(render!("{{chains.sut}}", context).unwrap(), expected);
}

#[rstest]
#[case::dynamic_select_selector_0(
SelectOptions::Dynamic { source: "{{chains.request}}".into(), selector: Some("$.array[*]".parse().unwrap())},
json!({"array": ["foo", "bar"]}),
0,
"foo"
)]
#[case::dynamic_select_selector_1(
SelectOptions::Dynamic { source: "{{chains.request}}".into(), selector: Some("$.array[*]".parse().unwrap())},
json!({"array": ["foo", "bar"]}),
1,
"bar"
)]
#[case::dynamic_select_0(
SelectOptions::Dynamic { source: "{{chains.request}}".into(), selector: None},
json!(["foo", "bar"]),
0,
"foo"
)]
#[case::dynamic_select_1(
SelectOptions::Dynamic { source: "{{chains.request}}".into(), selector: None},
json!(["foo", "bar"]),
1,
"bar"
)]
#[tokio::test]
async fn test_chain_dynamic_select(
#[case] options: SelectOptions,
#[case] chain_json: Value,
#[case] index: usize,
#[case] expected: &str,
) {
let profile = Profile {
data: indexmap! {"header".into() => "Token".into()},
..Profile::factory(())
};
let recipe = Recipe {
..Recipe::factory(())
};

let sut_chain = Chain {
id: "sut".into(),
source: ChainSource::Select {
message: Some("password".into()),
options,
},
..Chain::factory(())
};

let request_chain = Chain {
id: "request".into(),
source: ChainSource::Request {
recipe: recipe.id.clone(),
trigger: Default::default(),
section: Default::default(),
},
selector: None,
content_type: Some(ContentType::Json),
..Chain::factory(())
};

let database = CollectionDatabase::factory(());

let response_headers =
header_map(indexmap! {"Token" => "Secret Value"});

let request = RequestRecord {
recipe_id: recipe.id.clone(),
profile_id: Some(profile.id.clone()),
..RequestRecord::factory(())
};
let response = ResponseRecord {
body: chain_json.to_string().into_bytes().into(),
headers: response_headers,
..ResponseRecord::factory(())
};
database
.insert_exchange(&Exchange::factory((request, response)))
.unwrap();

let context = TemplateContext {
selected_profile: Some(profile.id.clone()),
collection: Collection {
recipes: by_id([recipe]).into(),
chains: by_id([sut_chain, request_chain]),
profiles: by_id([profile]),
..Collection::factory(())
}
.into(),
database,
prompter: Box::new(TestSelectPrompter::new(vec![index])),
..TemplateContext::factory(())
};

assert_eq!(render!("{{chains.sut}}", context).unwrap(), expected);
}

#[tokio::test]
async fn test_chain_select_error() {
let chain = Chain {
source: ChainSource::Select {
message: Some("password".into()),
options: vec!["foo".into(), "bar".into()],
options: SelectOptions::Fixed(vec!["foo".into(), "bar".into()]),
},
..Chain::factory(())
};
Expand All @@ -1015,6 +1108,51 @@ mod tests {
);
}

#[rstest]
#[case::invalid_json(
"not json",
"Dynamic option list failed to deserialize as JSON"
)]
#[case::not_array(
"{\"a\": 3}",
"Dynamic select options are invalid. Source must be an array or a \
selector must be provided."
)]
#[tokio::test]
async fn test_chain_select_dynamic_error(
#[case] input: &str,
#[case] expected_error: &str,
) {
let sut_chain = Chain {
source: ChainSource::Select {
message: Some("password".into()),
options: SelectOptions::Dynamic {
source: "{{chains.command}}".into(),
selector: None,
},
},
..Chain::factory(())
};

let command_chain = Chain {
id: "command".into(),
source: ChainSource::command(["echo", input]),
..Chain::factory(())
};

let context = TemplateContext {
collection: Collection {
chains: by_id([sut_chain, command_chain]),
..Collection::factory(())
}
.into(),
prompter: Box::new(TestSelectPrompter::new(vec![0usize])),
..TemplateContext::factory(())
};

assert_err!(render!("{{chains.chain1}}", context), expected_error);
}

/// Test that a chain being used twice only computes the chain once
#[tokio::test]
async fn test_chain_duplicate() {
Expand Down
14 changes: 14 additions & 0 deletions crates/slumber_core/src/template/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,20 @@ pub enum ChainError {
#[error("No response from prompt/select")]
PromptNoResponse,

/// We hit some sort of deserialization error while trying to build dynamic
/// options
#[error("Dynamic option list failed to deserialize as JSON")]
DynamicSelectOptions {
#[source]
error: Arc<serde_json::Error>,
},

#[error(
"Dynamic select options are invalid. Source must be an array or a \
selector must be provided."
)]
DynamicOptionsInvalid,

/// A bubbled-error from rendering a nested template in the chain arguments
#[error("Rendering nested template for field `{field}`")]
Nested {
Expand Down
Loading

0 comments on commit e4d61bb

Please sign in to comment.