Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions crates/goose/src/recipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire object/array input types into recipe rendering

Adding Object and Array as valid input_type values here lets these recipes parse and validate, but the active build path still renders with HashMap<String, String> (apply_values_to_parameters and render_recipe_content_with_params in crates/goose/src/recipe/build_recipe/mod.rs), so structured values are never materialized as JSON objects/arrays. In practice, templates using {{ signal.namespace }} or {% for item in findings %} will fail at render time even though the recipe schema now accepts those input types.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for calling this out. The latest push (2de4daa) addresses this: render_recipe_template now detects input_type: object/array and routes through render_recipe_content_with_structured_params, parsing the JSON string values into serde_json::Value before MiniJinja rendering. All existing callers benefit without API changes. Four integration tests through build_recipe_from_template cover this end-to-end (object, array, mixed, and invalid-JSON error cases).

}

impl fmt::Display for RecipeParameterInputType {
Expand Down Expand Up @@ -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));
}
}
193 changes: 193 additions & 0 deletions crates/goose/src/recipe/template_recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::recipe::{Recipe, BUILT_IN_RECIPE_DIR_PARAM};
use anyhow::Result;
use minijinja::{Environment, UndefinedBehavior};
use regex::Regex;
use serde_json::Value;

const CURRENT_TEMPLATE_NAME: &str = "recipe";
const OPEN_BRACE: &str = "{{";
Expand Down Expand Up @@ -114,6 +115,41 @@ pub fn render_recipe_content_with_params(
Ok(rendered_content)
}

/// Renders recipe content with structured parameters (objects, arrays, scalars).
///
/// This is the structured counterpart to `render_recipe_content_with_params`.
/// It accepts `serde_json::Value` parameters, enabling dot-notation access
/// for objects (`{{ signal.namespace }}`) and iteration for arrays
/// (`{% for item in findings %}`).
///
/// Existing scalar string parameters work identically via `Value::String`.
pub fn render_recipe_content_with_structured_params(
content: &str,
params: &HashMap<String, Value>,
) -> Result<String> {
let re = Regex::new(r#":\s*"""#).unwrap();
let content_with_empty_quotes_replaced = re.replace_all(content, ": ''");

let content_with_safe_variables =
preprocess_template_variables(&content_with_empty_quotes_replaced)?;

let recipe_dir = params
.get(BUILT_IN_RECIPE_DIR_PARAM)
.and_then(|v| v.as_str())
.map(|s| s.to_string());

let env = add_template_in_env(
&content_with_safe_variables,
recipe_dir,
UndefinedBehavior::Strict,
)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
let rendered_content = template
.render(params)
.map_err(|e| anyhow::anyhow!("Failed to render the recipe {}", e))?;
Ok(rendered_content)
}

fn add_template_in_env(
content: &str,
recipe_dir: Option<String>,
Expand Down Expand Up @@ -299,4 +335,161 @@ description: "A test recipe"
assert_eq!(result, "{{param_key}}");
}
}

mod render_structured_params_tests {
use std::collections::HashMap;

use crate::recipe::template_recipe::render_recipe_content_with_structured_params;
use serde_json::{json, Value};

fn str_val(s: &str) -> Value {
Value::String(s.to_string())
}

#[test]
fn test_render_with_string_values_unchanged() {
let content = "Hello {{ name }}!";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
("name".to_string(), str_val("World")),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "Hello World!");
}

#[test]
fn test_render_with_object_parameter() {
let content = "Signal: {{ signal.name }} in {{ signal.namespace }}";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
(
"signal".to_string(),
json!({"name": "OOMKilled", "namespace": "production"}),
),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "Signal: OOMKilled in production");
}

#[test]
fn test_render_with_object_conditional() {
let content =
r#"{% if signal.severity == "critical" %}CRITICAL{% else %}normal{% endif %}"#;
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
(
"signal".to_string(),
json!({"severity": "critical", "name": "OOMKilled"}),
),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "CRITICAL");
}

#[test]
fn test_render_with_array_parameter() {
let content = "{% for item in findings %}{{ item.name }}: {{ item.output }}
{% endfor %}";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
(
"findings".to_string(),
json!([
{"name": "check-1", "output": "passed"},
{"name": "check-2", "output": "failed"}
]),
),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(
result,
"check-1: passed
check-2: failed
"
);
}

#[test]
fn test_render_with_nested_object_access() {
let content = "Owner: {{ enrichment.owner_chain }}";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
(
"enrichment".to_string(),
json!({"owner_chain": "Pod > ReplicaSet > Deployment", "labels": {"app": "api"}}),
),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "Owner: Pod > ReplicaSet > Deployment");
}

#[test]
fn test_render_with_optional_object_field() {
let content = "{% if enrichment.owner_chain is defined %}Chain: {{ enrichment.owner_chain }}{% else %}No chain{% endif %}";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
("enrichment".to_string(), json!({})),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "No chain");
}

#[test]
fn test_render_mixed_scalar_and_object_params() {
let content =
"Alert {{ alert_name }} for {{ signal.resource_kind }}/{{ signal.resource_name }}";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
("alert_name".to_string(), str_val("HighMemory")),
(
"signal".to_string(),
json!({"resource_kind": "Pod", "resource_name": "api-server-abc"}),
),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "Alert HighMemory for Pod/api-server-abc");
}

#[test]
fn test_render_with_empty_array_iteration() {
let content = "Items:{% for item in findings %} {{ item.name }}{% endfor %} done";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
("findings".to_string(), json!([])),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "Items: done");
}

#[test]
fn test_render_with_deeply_nested_object() {
let content = "Owner: {{ signal.metadata.labels.app }}";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
(
"signal".to_string(),
json!({"metadata": {"labels": {"app": "api-server", "env": "prod"}}}),
),
]);
let result = render_recipe_content_with_structured_params(content, &params).unwrap();
assert_eq!(result, "Owner: api-server");
}

#[test]
fn test_render_dot_access_on_scalar_fails() {
let content = "Value: {{ name.field }}";
let params = HashMap::from([
("recipe_dir".to_string(), str_val("some_dir")),
("name".to_string(), str_val("just-a-string")),
]);
let result = render_recipe_content_with_structured_params(content, &params);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Failed to render"),
"Expected render failure, got: {}",
err_msg
);
}
}
}
Loading
Loading