use super::*;
pub(super) fn expand_for_each(
value: &Value,
bindings: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
) -> Result<Value, String> {
match value {
Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
if let Some(loop_name) = k.strip_prefix("Fn::ForEach::") {
let arr = v.as_array().ok_or_else(|| {
format!("Fn::ForEach::{loop_name} requires an array argument")
})?;
if arr.len() != 3 {
return Err(format!(
"Fn::ForEach::{loop_name} requires 3 arguments (loopVar, list, template), got {}",
arr.len()
));
}
let loop_var = arr[0].as_str().ok_or_else(|| {
format!("Fn::ForEach::{loop_name} loop variable must be a string")
})?;
let items_owned: Vec<Value> =
resolve_for_each_items(&arr[1], parameters).ok_or_else(|| {
format!(
"Fn::ForEach::{loop_name} second argument must be an array or a Ref to a CommaDelimitedList parameter"
)
})?;
let body = arr[2].as_object().ok_or_else(|| {
format!("Fn::ForEach::{loop_name} third argument must be an object")
})?;
for item in &items_owned {
let item_str = match item {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let mut next = bindings.clone();
next.insert(loop_var.to_string(), item_str.clone());
let body_value = Value::Object(body.clone());
let substituted = substitute_loop_vars_in_value(&body_value, &next);
let expanded = expand_for_each(&substituted, &next, parameters)?;
if let Value::Object(emitted) = expanded {
for (ek, ev) in emitted {
out.insert(ek, ev);
}
}
}
continue;
}
out.insert(k.clone(), expand_for_each(v, bindings, parameters)?);
}
Ok(Value::Object(out))
}
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
out.push(expand_for_each(v, bindings, parameters)?);
}
Ok(Value::Array(out))
}
other => Ok(other.clone()),
}
}
pub(super) fn expand_sam(value: &Value) -> Value {
let transform = value.get("Transform");
let has_sam = match transform {
Some(Value::String(s)) => s == "AWS::Serverless-2016-10-31",
Some(Value::Array(arr)) => arr
.iter()
.any(|v| v.as_str() == Some("AWS::Serverless-2016-10-31")),
_ => false,
};
if !has_sam {
return value.clone();
}
let global = |section: &str| {
value
.get("Globals")
.and_then(|g| g.get(section))
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default()
};
let global_function = global("Function");
let global_api = global("Api");
let global_http_api = global("HttpApi");
let global_simple_table = global("SimpleTable");
let global_state_machine = global("StateMachine");
let mut value = value.clone();
let Some(resources) = value.get_mut("Resources") else {
return value;
};
let Some(resources_map) = resources.as_object_mut() else {
return value;
};
let mut new_resources = serde_json::Map::new();
for (logical_id, resource) in resources_map.iter() {
let Some(resource_obj) = resource.as_object() else {
new_resources.insert(logical_id.clone(), resource.clone());
continue;
};
let Some(ty) = resource_obj.get("Type").and_then(|v| v.as_str()) else {
new_resources.insert(logical_id.clone(), resource.clone());
continue;
};
let properties = resource_obj
.get("Properties")
.cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
match ty {
"AWS::Serverless::Function" => {
let mut lambda_props = merge_global_properties(&global_function, &properties);
if let Some(code_uri) = lambda_props.get("CodeUri").cloned() {
lambda_props.remove("CodeUri");
let code = if let Some(s) = code_uri.as_str() {
if let Some(stripped) = s.strip_prefix("s3://") {
let parts: Vec<&str> = stripped.splitn(2, '/').collect();
if parts.len() == 2 {
json!({"S3Bucket": parts[0], "S3Key": parts[1]})
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
code_uri
};
lambda_props.insert("Code".to_string(), code);
} else if let Some(inline) = lambda_props.get("InlineCode").cloned() {
lambda_props.remove("InlineCode");
lambda_props.insert("Code".to_string(), json!({"ZipFile": inline}));
}
let mut lambda_resource = serde_json::Map::new();
lambda_resource.insert("Type".to_string(), json!("AWS::Lambda::Function"));
lambda_resource.insert("Properties".to_string(), Value::Object(lambda_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
lambda_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(lambda_resource));
}
"AWS::Serverless::Api" => {
let mut api_props = merge_global_properties(&global_api, &properties);
if let Some(def) = api_props.get("DefinitionBody").cloned() {
api_props.remove("DefinitionBody");
api_props.insert("Body".to_string(), def);
}
let mut api_resource = serde_json::Map::new();
api_resource.insert("Type".to_string(), json!("AWS::ApiGateway::RestApi"));
api_resource.insert("Properties".to_string(), Value::Object(api_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
api_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(api_resource));
}
"AWS::Serverless::HttpApi" => {
let httpapi_props = merge_global_properties(&global_http_api, &properties);
let mut httpapi_resource = serde_json::Map::new();
httpapi_resource.insert("Type".to_string(), json!("AWS::ApiGatewayV2::Api"));
httpapi_resource.insert("Properties".to_string(), Value::Object(httpapi_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
httpapi_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(httpapi_resource));
}
"AWS::Serverless::SimpleTable" => {
let mut table_props = merge_global_properties(&global_simple_table, &properties);
if let Some(pk) = table_props.get("PrimaryKey") {
if let Some(pk_obj) = pk.as_object() {
let name = pk_obj.get("Name").cloned().unwrap_or_else(|| json!("id"));
let ty = match pk_obj.get("Type").and_then(|v| v.as_str()) {
Some("String") => json!("S"),
Some("Number") => json!("N"),
Some("Binary") => json!("B"),
Some(other) => json!(other),
None => json!("S"),
};
table_props.remove("PrimaryKey");
table_props.insert(
"KeySchema".to_string(),
json!([{"AttributeName": name.clone(), "KeyType": "HASH"}]),
);
table_props.insert(
"AttributeDefinitions".to_string(),
json!([{"AttributeName": name, "AttributeType": ty}]),
);
}
}
if !table_props.contains_key("BillingMode") {
table_props.insert("BillingMode".to_string(), json!("PAY_PER_REQUEST"));
}
let mut table_resource = serde_json::Map::new();
table_resource.insert("Type".to_string(), json!("AWS::DynamoDB::Table"));
table_resource.insert("Properties".to_string(), Value::Object(table_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
table_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(table_resource));
}
"AWS::Serverless::LayerVersion" => {
let mut layer_props = if let Some(p) = properties.as_object() {
p.clone()
} else {
serde_json::Map::new()
};
if let Some(uri) = layer_props.get("ContentUri").cloned() {
layer_props.remove("ContentUri");
let content = if let Some(s) = uri.as_str() {
if let Some(stripped) = s.strip_prefix("s3://") {
let parts: Vec<&str> = stripped.splitn(2, '/').collect();
if parts.len() == 2 {
json!({"S3Bucket": parts[0], "S3Key": parts[1]})
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
json!({"S3Bucket": "sam", "S3Key": s})
}
} else {
uri
};
layer_props.insert("Content".to_string(), content);
}
let mut layer_resource = serde_json::Map::new();
layer_resource.insert("Type".to_string(), json!("AWS::Lambda::LayerVersion"));
layer_resource.insert("Properties".to_string(), Value::Object(layer_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
layer_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(layer_resource));
}
"AWS::Serverless::StateMachine" => {
let mut sfn_props = merge_global_properties(&global_state_machine, &properties);
if let Some(uri) = sfn_props.get("DefinitionUri").cloned() {
sfn_props.remove("DefinitionUri");
let location = if let Some(s) = uri.as_str() {
if let Some(stripped) = s.strip_prefix("s3://") {
let parts: Vec<&str> = stripped.splitn(2, '/').collect();
if parts.len() == 2 {
json!({"Bucket": parts[0], "Key": parts[1]})
} else {
json!({"Bucket": "sam", "Key": s})
}
} else {
json!({"Bucket": "sam", "Key": s})
}
} else {
uri
};
sfn_props.insert("DefinitionS3Location".to_string(), location);
}
if let Some(role) = sfn_props.remove("Role") {
sfn_props.insert("RoleArn".to_string(), role);
}
if let Some(name) = sfn_props.remove("Name") {
sfn_props.insert("StateMachineName".to_string(), name);
}
if let Some(machine_type) = sfn_props.remove("Type") {
sfn_props.insert("StateMachineType".to_string(), machine_type);
}
sfn_props.remove("Events");
let mut sfn_resource = serde_json::Map::new();
sfn_resource.insert(
"Type".to_string(),
json!("AWS::StepFunctions::StateMachine"),
);
sfn_resource.insert("Properties".to_string(), Value::Object(sfn_props));
for (k, v) in resource_obj {
if k != "Type" && k != "Properties" {
sfn_resource.insert(k.clone(), v.clone());
}
}
new_resources.insert(logical_id.clone(), Value::Object(sfn_resource));
}
_ => {
new_resources.insert(logical_id.clone(), resource.clone());
}
}
}
resources_map.clear();
for (k, v) in new_resources {
resources_map.insert(k, v);
}
value
}
fn merge_global_properties(
globals: &serde_json::Map<String, Value>,
properties: &Value,
) -> serde_json::Map<String, Value> {
let mut merged = globals.clone();
if let Some(p) = properties.as_object() {
for (k, v) in p {
match merged.remove(k) {
Some(global_v) => {
merged.insert(k.clone(), merge_global_value(k, global_v, v.clone()));
}
None => {
merged.insert(k.clone(), v.clone());
}
}
}
}
merged
}
const ADDITIVE_GLOBAL_LISTS: &[&str] = &["Layers", "Policies"];
fn merge_global_value(key: &str, global_v: Value, resource_v: Value) -> Value {
match (global_v, resource_v) {
(Value::Object(global_map), Value::Object(resource_map)) => {
let mut out = global_map;
for (k, v) in resource_map {
match out.remove(&k) {
Some(existing) => {
out.insert(k.clone(), merge_global_value(&k, existing, v));
}
None => {
out.insert(k, v);
}
}
}
Value::Object(out)
}
(Value::Array(mut global_list), Value::Array(resource_list))
if ADDITIVE_GLOBAL_LISTS.contains(&key) =>
{
global_list.extend(resource_list);
Value::Array(global_list)
}
(_, resource_v) => resource_v,
}
}
pub(super) fn resolve_for_each_items(
value: &Value,
parameters: &BTreeMap<String, String>,
) -> Option<Vec<Value>> {
if let Some(arr) = value.as_array() {
return Some(arr.clone());
}
if let Some(map) = value.as_object() {
if let Some(name) = map.get("Ref").and_then(|v| v.as_str()) {
let raw = parameters.get(name)?;
return Some(
raw.split(',')
.map(|p| Value::String(p.trim().to_string()))
.collect(),
);
}
}
None
}
pub(super) fn substitute_loop_vars(s: &str, bindings: &BTreeMap<String, String>) -> String {
let mut result = s.to_string();
for (k, v) in bindings {
result = result.replace(&format!("${{{k}}}"), v);
result = result.replace(&format!("&{{{k}}}"), v);
}
result
}
pub(super) fn substitute_loop_vars_in_value(
value: &Value,
bindings: &BTreeMap<String, String>,
) -> Value {
match value {
Value::String(s) => Value::String(substitute_loop_vars(s, bindings)),
Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
let new_key = substitute_loop_vars(k, bindings);
out.insert(new_key, substitute_loop_vars_in_value(v, bindings));
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(
arr.iter()
.map(|v| substitute_loop_vars_in_value(v, bindings))
.collect(),
),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_sam_applies_function_globals() {
let template = json!({
"Transform": "AWS::Serverless-2016-10-31",
"Globals": {
"Function": {
"Handler": "index.lambda_handler",
"Runtime": "python3.13",
"Timeout": 300,
"MemorySize": 256
}
},
"Resources": {
"Dispatcher": {
"Type": "AWS::Serverless::Function",
"Properties": {
"FunctionName": "workflow_dispatcher_v2",
"InlineCode": "def lambda_handler(e, c): return e"
}
}
}
});
let props = &expand_sam(&template)["Resources"]["Dispatcher"]["Properties"];
assert_eq!(props["Handler"], json!("index.lambda_handler"));
assert_eq!(props["Runtime"], json!("python3.13"));
assert_eq!(props["Timeout"], json!(300));
assert_eq!(props["MemorySize"], json!(256));
assert_eq!(props["FunctionName"], json!("workflow_dispatcher_v2"));
}
#[test]
fn expand_sam_function_overrides_globals() {
let template = json!({
"Transform": "AWS::Serverless-2016-10-31",
"Globals": {"Function": {"Handler": "index.lambda_handler", "Runtime": "python3.13"}},
"Resources": {
"F": {
"Type": "AWS::Serverless::Function",
"Properties": {"Handler": "app.main", "InlineCode": "x"}
}
}
});
let props = &expand_sam(&template)["Resources"]["F"]["Properties"];
assert_eq!(
props["Handler"],
json!("app.main"),
"per-function Handler wins"
);
assert_eq!(
props["Runtime"],
json!("python3.13"),
"global Runtime still applies"
);
}
#[test]
fn expand_sam_function_globals_deep_merge_maps() {
let template = json!({
"Transform": "AWS::Serverless-2016-10-31",
"Globals": {
"Function": {
"Environment": {"Variables": {"STAGE": "prod", "REGION": "us-east-1"}},
"Tags": {"team": "core", "env": "prod"}
}
},
"Resources": {
"F": {
"Type": "AWS::Serverless::Function",
"Properties": {
"Handler": "app.main",
"Runtime": "python3.13",
"Environment": {"Variables": {"REGION": "eu-west-1", "DEBUG": "1"}},
"Tags": {"env": "staging"}
}
}
}
});
let props = &expand_sam(&template)["Resources"]["F"]["Properties"];
assert_eq!(props["Environment"]["Variables"]["STAGE"], json!("prod"));
assert_eq!(
props["Environment"]["Variables"]["REGION"],
json!("eu-west-1")
);
assert_eq!(props["Environment"]["Variables"]["DEBUG"], json!("1"));
assert_eq!(props["Tags"]["team"], json!("core"));
assert_eq!(props["Tags"]["env"], json!("staging"));
}
#[test]
fn expand_sam_function_globals_additive_lists() {
let template = json!({
"Transform": "AWS::Serverless-2016-10-31",
"Globals": {
"Function": {
"Layers": ["arn:aws:lambda:::layer:global:1"],
"Policies": ["AWSLambdaBasicExecutionRole"]
}
},
"Resources": {
"F": {
"Type": "AWS::Serverless::Function",
"Properties": {
"Handler": "app.main",
"Runtime": "python3.13",
"Layers": ["arn:aws:lambda:::layer:local:2"],
"Policies": ["AmazonS3ReadOnlyAccess"]
}
}
}
});
let props = &expand_sam(&template)["Resources"]["F"]["Properties"];
assert_eq!(
props["Layers"],
json!([
"arn:aws:lambda:::layer:global:1",
"arn:aws:lambda:::layer:local:2"
])
);
assert_eq!(
props["Policies"],
json!(["AWSLambdaBasicExecutionRole", "AmazonS3ReadOnlyAccess"])
);
}
#[test]
fn expand_sam_applies_non_function_globals() {
let template = json!({
"Transform": "AWS::Serverless-2016-10-31",
"Globals": {
"Api": {"Cors": "'*'"},
"SimpleTable": {"SSESpecification": {"SSEEnabled": true}}
},
"Resources": {
"Gw": {"Type": "AWS::Serverless::Api", "Properties": {"StageName": "prod"}},
"Tbl": {"Type": "AWS::Serverless::SimpleTable", "Properties": {}}
}
});
let expanded = expand_sam(&template);
assert_eq!(
expanded["Resources"]["Gw"]["Properties"]["Cors"],
json!("'*'")
);
assert_eq!(
expanded["Resources"]["Gw"]["Properties"]["StageName"],
json!("prod")
);
assert_eq!(
expanded["Resources"]["Tbl"]["Properties"]["SSESpecification"]["SSEEnabled"],
json!(true)
);
}
#[test]
fn expand_sam_statemachine_inline_definition() {
let template = json!({
"Transform": "AWS::Serverless-2016-10-31",
"Resources": {
"MySM": {
"Type": "AWS::Serverless::StateMachine",
"DependsOn": "SomeOtherResource",
"Properties": {
"Definition": {
"StartAt": "Done",
"States": {"Done": {"Type": "Succeed"}}
},
"DefinitionSubstitutions": {"fn": "my-fn"},
"Role": "arn:aws:iam::123456789012:role/sfn-role",
"Name": "my-state-machine",
"Type": "EXPRESS"
}
}
}
});
let expanded = expand_sam(&template);
let resource = &expanded["Resources"]["MySM"];
assert_eq!(resource["Type"], json!("AWS::StepFunctions::StateMachine"));
let props = &resource["Properties"];
assert_eq!(
props["RoleArn"],
json!("arn:aws:iam::123456789012:role/sfn-role")
);
assert_eq!(props["StateMachineName"], json!("my-state-machine"));
assert_eq!(props["StateMachineType"], json!("EXPRESS"));
assert_eq!(
props["Definition"],
json!({"StartAt": "Done", "States": {"Done": {"Type": "Succeed"}}})
);
assert_eq!(props["DefinitionSubstitutions"], json!({"fn": "my-fn"}));
assert!(props.get("Role").is_none());
assert!(props.get("Name").is_none());
assert_eq!(resource["DependsOn"], json!("SomeOtherResource"));
}
#[test]
fn expand_sam_statemachine_definition_uri() {
let template = json!({
"Transform": "AWS::Serverless-2016-10-31",
"Resources": {
"MySM": {
"Type": "AWS::Serverless::StateMachine",
"Properties": {
"DefinitionUri": "s3://my-bucket/path/to/def.asl.json",
"Role": "arn:aws:iam::123456789012:role/sfn-role"
}
}
}
});
let expanded = expand_sam(&template);
let props = &expanded["Resources"]["MySM"]["Properties"];
assert_eq!(
props["DefinitionS3Location"],
json!({"Bucket": "my-bucket", "Key": "path/to/def.asl.json"})
);
assert!(props.get("DefinitionUri").is_none());
assert_eq!(
props["RoleArn"],
json!("arn:aws:iam::123456789012:role/sfn-role")
);
}
}