-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(recipe): support structured parameters (object/array) in recipe templates #8934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
77e977e
37d6a7a
7c5188c
2d5ca93
adb4984
2de4daa
83db531
f174a24
811598c
8d152fd
7626d92
3e95363
826a5cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,14 @@ | ||
| use crate::recipe::read_recipe_file_content::read_parameter_file_content; | ||
| use crate::recipe::template_recipe::render_recipe_content_with_params; | ||
| use crate::recipe::template_recipe::{ | ||
| render_recipe_content_with_params, render_recipe_content_with_structured_params, | ||
| }; | ||
| use crate::recipe::validate_recipe::validate_recipe_template_from_content; | ||
| use crate::recipe::{ | ||
| Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, | ||
| BUILT_IN_RECIPE_DIR_PARAM, | ||
| }; | ||
| use anyhow::Result; | ||
| use serde_json::Value; | ||
| use std::collections::HashMap; | ||
| use std::path::Path; | ||
|
|
||
|
|
@@ -32,18 +35,72 @@ where | |
| validate_recipe_template_from_content(&recipe_content, Some(recipe_dir_str.clone()))? | ||
| .parameters; | ||
|
|
||
| let (params_for_template, missing_params) = | ||
| apply_values_to_parameters(¶ms, recipe_parameters, &recipe_dir_str, user_prompt_fn)?; | ||
| let has_structured_params = recipe_parameters.as_ref().is_some_and(|params| { | ||
| params.iter().any(|p| { | ||
| matches!( | ||
| p.input_type, | ||
| RecipeParameterInputType::Object | RecipeParameterInputType::Array | ||
| ) | ||
| }) | ||
| }); | ||
|
|
||
| let (params_for_template, missing_params) = apply_values_to_parameters( | ||
| ¶ms, | ||
| recipe_parameters.clone(), | ||
| &recipe_dir_str, | ||
| user_prompt_fn, | ||
| )?; | ||
|
|
||
| let rendered_content = if missing_params.is_empty() { | ||
| render_recipe_content_with_params(&recipe_content, ¶ms_for_template)? | ||
| if has_structured_params { | ||
| let structured_map = | ||
| to_structured_params(¶ms_for_template, recipe_parameters.as_deref())?; | ||
| render_recipe_content_with_structured_params(&recipe_content, &structured_map)? | ||
| } else { | ||
| render_recipe_content_with_params(&recipe_content, ¶ms_for_template)? | ||
| } | ||
| } else { | ||
| String::new() | ||
| }; | ||
|
|
||
| Ok((rendered_content, missing_params)) | ||
| } | ||
|
|
||
| fn to_structured_params( | ||
| string_map: &HashMap<String, String>, | ||
| recipe_parameters: Option<&[RecipeParameter]>, | ||
| ) -> Result<HashMap<String, Value>> { | ||
| let structured_keys: std::collections::HashSet<&str> = recipe_parameters | ||
| .unwrap_or_default() | ||
| .iter() | ||
| .filter(|p| { | ||
| matches!( | ||
| p.input_type, | ||
| RecipeParameterInputType::Object | RecipeParameterInputType::Array | ||
| ) | ||
| }) | ||
| .map(|p| p.key.as_str()) | ||
| .collect(); | ||
|
|
||
| string_map | ||
| .iter() | ||
| .map(|(k, v)| { | ||
| let value = if structured_keys.contains(k.as_str()) { | ||
| serde_json::from_str(v).map_err(|e| { | ||
| anyhow::anyhow!( | ||
| "Parameter '{}' has input_type object/array but value is not valid JSON: {}", | ||
| k, | ||
| e | ||
| ) | ||
| })? | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call. Fixed in 83db531 -- |
||
| } else { | ||
| Value::String(v.clone()) | ||
| }; | ||
| Ok((k.clone(), value)) | ||
| }) | ||
| .collect() | ||
| } | ||
|
|
||
| pub fn build_recipe_from_template<F>( | ||
| recipe_content: String, | ||
| recipe_dir: &Path, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -181,6 +181,12 @@ pub enum RecipeParameterInputType { | |
| /// Cannot have default values to prevent importing sensitive user files. | ||
| File, | ||
| Select, | ||
| /// Structured object parameter passed as JSON. | ||
| /// Enables dot-notation access in templates: `{{ param.field }}` | ||
| Object, | ||
| /// Array parameter passed as a JSON array. | ||
| /// Enables iteration in templates: `{% for item in param %}` | ||
| Array, | ||
|
Comment on lines
+186
to
+189
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for calling this out. The latest push (2de4daa) addresses this: |
||
| } | ||
|
|
||
| impl fmt::Display for RecipeParameterInputType { | ||
|
|
@@ -873,4 +879,132 @@ isGlobal: true"#; | |
| "settings.temperature: invalid type: string \"not_a_number\", expected f32" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_from_content_json_with_object_parameter() { | ||
| let content = r#"{ | ||
| "version": "1.0.0", | ||
| "title": "Object Param Recipe", | ||
| "description": "Recipe with object parameter", | ||
| "instructions": "Analyze {{ signal.name }} in {{ signal.namespace }}", | ||
| "parameters": [ | ||
| { | ||
| "key": "signal", | ||
| "input_type": "object", | ||
| "requirement": "required", | ||
| "description": "Signal data with name and namespace" | ||
| } | ||
| ] | ||
| }"#; | ||
|
|
||
| let recipe = Recipe::from_content(content).unwrap(); | ||
| assert!(recipe.parameters.is_some()); | ||
| let params = recipe.parameters.unwrap(); | ||
| assert_eq!(params.len(), 1); | ||
| assert_eq!(params[0].key, "signal"); | ||
| assert!(matches!( | ||
| params[0].input_type, | ||
| RecipeParameterInputType::Object | ||
| )); | ||
| assert!(matches!( | ||
| params[0].requirement, | ||
| RecipeParameterRequirement::Required | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_from_content_json_with_array_parameter() { | ||
| let content = r#"{ | ||
| "version": "1.0.0", | ||
| "title": "Array Param Recipe", | ||
| "description": "Recipe with array parameter", | ||
| "instructions": "Process findings", | ||
| "parameters": [ | ||
| { | ||
| "key": "findings", | ||
| "input_type": "array", | ||
| "requirement": "required", | ||
| "description": "List of diagnostic findings" | ||
| } | ||
| ] | ||
| }"#; | ||
|
|
||
| let recipe = Recipe::from_content(content).unwrap(); | ||
| assert!(recipe.parameters.is_some()); | ||
| let params = recipe.parameters.unwrap(); | ||
| assert_eq!(params.len(), 1); | ||
| assert_eq!(params[0].key, "findings"); | ||
| assert!(matches!( | ||
| params[0].input_type, | ||
| RecipeParameterInputType::Array | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_from_content_yaml_with_object_and_array_parameters() { | ||
| let content = r#"version: 1.0.0 | ||
| title: Mixed Structured Params | ||
| description: Recipe with object and array parameters | ||
| instructions: Analyze signal and findings | ||
| parameters: | ||
| - key: signal | ||
| input_type: object | ||
| requirement: required | ||
| description: Signal data | ||
| - key: findings | ||
| input_type: array | ||
| requirement: optional | ||
| description: Diagnostic findings | ||
| - key: cluster_name | ||
| input_type: string | ||
| requirement: required | ||
| description: Target cluster"#; | ||
|
|
||
| let recipe = Recipe::from_content(content).unwrap(); | ||
| assert!(recipe.parameters.is_some()); | ||
| let params = recipe.parameters.unwrap(); | ||
| assert_eq!(params.len(), 3); | ||
|
|
||
| assert_eq!(params[0].key, "signal"); | ||
| assert!(matches!( | ||
| params[0].input_type, | ||
| RecipeParameterInputType::Object | ||
| )); | ||
| assert!(matches!( | ||
| params[0].requirement, | ||
| RecipeParameterRequirement::Required | ||
| )); | ||
|
|
||
| assert_eq!(params[1].key, "findings"); | ||
| assert!(matches!( | ||
| params[1].input_type, | ||
| RecipeParameterInputType::Array | ||
| )); | ||
| assert!(matches!( | ||
| params[1].requirement, | ||
| RecipeParameterRequirement::Optional | ||
| )); | ||
|
|
||
| assert_eq!(params[2].key, "cluster_name"); | ||
| assert!(matches!( | ||
| params[2].input_type, | ||
| RecipeParameterInputType::String | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_object_array_input_type_serde_roundtrip() { | ||
| let object_type = RecipeParameterInputType::Object; | ||
| let array_type = RecipeParameterInputType::Array; | ||
|
|
||
| let object_json = serde_json::to_string(&object_type).unwrap(); | ||
| let array_json = serde_json::to_string(&array_type).unwrap(); | ||
| assert_eq!(object_json, "\"object\""); | ||
| assert_eq!(array_json, "\"array\""); | ||
|
|
||
| let deser_object: RecipeParameterInputType = serde_json::from_str(&object_json).unwrap(); | ||
| let deser_array: RecipeParameterInputType = serde_json::from_str(&array_json).unwrap(); | ||
| assert!(matches!(deser_object, RecipeParameterInputType::Object)); | ||
| assert!(matches!(deser_array, RecipeParameterInputType::Array)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Object/array values are first parsed in
to_structured_params, which runs only in the rendering path. As a result, an optional structured parameter with an invalid default (for example, non-JSON text) passes recipe validation and only fails when the default is injected and parsed during build, turning a schema error into a late runtime error.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. Fixed in 3e95363 --
validate_optional_parametersnow checks that object/array parameter defaults are valid JSON matching theirinput_type(object must be{}, array must be[]). Invalid defaults are rejected during recipe validation instead of surfacing as late runtime errors.