Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -1280,7 +1280,7 @@ private JsonNode applyOutputPath(JsonNode stateDef, JsonNode input, JsonNode out
return resolvePath(path, output);
}

private JsonNode resolveParameters(JsonNode parameters, JsonNode input, JsonNode context) throws Exception {
JsonNode resolveParameters(JsonNode parameters, JsonNode input, JsonNode context) throws Exception {
if (parameters.isObject()) {
ObjectNode resolved = objectMapper.createObjectNode();
Iterator<Map.Entry<String, JsonNode>> fields = parameters.fields();
Expand All @@ -1297,25 +1297,47 @@ private JsonNode resolveParameters(JsonNode parameters, JsonNode input, JsonNode
} else if ("$$".equals(path)) {
resolved.set(realKey, context);
} else {
resolved.set(realKey, resolvePath(path, input));
// Pass the Context Object through so a $$. reference nested inside an
// intrinsic (e.g. States.Format(..., $$.Map.Item.Value.x)) can resolve it;
// for a plain $. or States.* input reference, context is simply ignored.
resolved.set(realKey, resolvePath(path, input, context));
}
} else if (val.isObject()) {
} else if (val.isObject() || val.isArray()) {
resolved.set(key, resolveParameters(val, input, context));
} else {
resolved.set(key, val);
}
}
return resolved;
}
if (parameters.isArray()) {
// Payload templates resolve .$ references at any depth, including inside arrays
// (e.g. an ECS Overrides.ContainerOverrides[].Environment[].Value.$).
ArrayNode resolvedArray = objectMapper.createArrayNode();
for (JsonNode element : parameters) {
resolvedArray.add(resolveParameters(element, input, context));
}
return resolvedArray;
}
return parameters;
}

JsonNode resolvePath(String path, JsonNode root) {
return resolvePath(path, root, null);
}

/**
* Resolve a JSONPath reference or {@code States.*} intrinsic. When {@code context} is
* non-null it is the Context Object ({@code $$}), letting intrinsic arguments reference it
* (e.g. a {@code $$.Map.Item.Value.x} argument nested inside {@code States.Format(...)}).
* It is null for ordinary input-only resolution, which preserves existing behavior.
*/
JsonNode resolvePath(String path, JsonNode root, JsonNode context) {
if (path == null || "$".equals(path)) {
return root;
}
if (path.startsWith("States.")) {
return evaluateIntrinsic(path, root);
return evaluateIntrinsic(path, root, context);
}
if (!path.startsWith("$.")) {
return NullNode.getInstance();
Expand Down Expand Up @@ -1346,7 +1368,7 @@ JsonNode resolvePath(String path, JsonNode root) {
* States.Array, States.ArrayLength, States.MathAdd, States.UUID.
* Throws FailStateException("States.Runtime") for unrecognized functions.
*/
private JsonNode evaluateIntrinsic(String expr, JsonNode root) {
private JsonNode evaluateIntrinsic(String expr, JsonNode root, JsonNode context) {
int parenOpen = expr.indexOf('(');
int parenClose = expr.lastIndexOf(')');
if (parenOpen < 0 || parenClose < 0) {
Expand All @@ -1357,7 +1379,7 @@ private JsonNode evaluateIntrinsic(String expr, JsonNode root) {

return switch (fnName) {
case "States.StringToJson" -> {
JsonNode arg = resolveIntrinsicArg(argsStr, root);
JsonNode arg = resolveIntrinsicArg(argsStr, root, context);
try {
yield objectMapper.readTree(arg.asText());
} catch (Exception e) {
Expand All @@ -1366,7 +1388,7 @@ private JsonNode evaluateIntrinsic(String expr, JsonNode root) {
}
}
case "States.JsonToString" -> {
JsonNode arg = resolveIntrinsicArg(argsStr, root);
JsonNode arg = resolveIntrinsicArg(argsStr, root, context);
try {
yield objectMapper.getNodeFactory().textNode(objectMapper.writeValueAsString(arg));
} catch (Exception e) {
Expand All @@ -1386,7 +1408,7 @@ private JsonNode evaluateIntrinsic(String expr, JsonNode root) {
if (argIdx >= parts.size()) {
throw new FailStateException("States.Runtime", "States.Format: not enough arguments");
}
JsonNode argVal = resolveIntrinsicArg(parts.get(argIdx++).trim(), root);
JsonNode argVal = resolveIntrinsicArg(parts.get(argIdx++).trim(), root, context);
sb.append(argVal.isTextual() ? argVal.asText() : argVal.toString());
i++; // skip '}'
} else {
Expand All @@ -1399,12 +1421,12 @@ private JsonNode evaluateIntrinsic(String expr, JsonNode root) {
List<String> parts = splitIntrinsicArgs(argsStr);
ArrayNode arr = objectMapper.createArrayNode();
for (String part : parts) {
arr.add(resolveIntrinsicArg(part.trim(), root));
arr.add(resolveIntrinsicArg(part.trim(), root, context));
}
yield arr;
}
case "States.ArrayLength" -> {
JsonNode arg = resolveIntrinsicArg(argsStr, root);
JsonNode arg = resolveIntrinsicArg(argsStr, root, context);
if (!arg.isArray()) {
throw new FailStateException("States.Runtime", "States.ArrayLength requires an array");
}
Expand All @@ -1415,8 +1437,8 @@ private JsonNode evaluateIntrinsic(String expr, JsonNode root) {
if (parts.size() != 2) {
throw new FailStateException("States.Runtime", "States.MathAdd requires exactly 2 arguments");
}
JsonNode a = resolveIntrinsicArg(parts.get(0).trim(), root);
JsonNode b = resolveIntrinsicArg(parts.get(1).trim(), root);
JsonNode a = resolveIntrinsicArg(parts.get(0).trim(), root, context);
JsonNode b = resolveIntrinsicArg(parts.get(1).trim(), root, context);
yield objectMapper.getNodeFactory().numberNode(a.asLong() + b.asLong());
}
case "States.UUID" -> {
Expand All @@ -1431,10 +1453,19 @@ private JsonNode evaluateIntrinsic(String expr, JsonNode root) {
* Resolve a single intrinsic argument: either a $.path reference, a quoted string literal,
* or a numeric literal.
*/
private JsonNode resolveIntrinsicArg(String arg, JsonNode root) {
private JsonNode resolveIntrinsicArg(String arg, JsonNode root, JsonNode context) {
arg = arg.trim();
// A $$.-prefixed argument references the Context Object; resolve it against context
// (as a $. path) so intrinsics can read e.g. $$.Map.Item.Value.x or $$.Execution.Id.
// When context is null (input-only resolution) these fall through unchanged.
if (context != null && arg.startsWith("$$.")) {
return resolvePath("$." + arg.substring(3), context);
}
if (context != null && "$$".equals(arg)) {
return context;
}
if (arg.startsWith("$.") || "$".equals(arg)) {
return resolvePath(arg, root);
return resolvePath(arg, root, context);
}
if (arg.startsWith("'") && arg.endsWith("'")) {
return objectMapper.getNodeFactory().textNode(arg.substring(1, arg.length() - 1));
Expand All @@ -1448,8 +1479,8 @@ private JsonNode resolveIntrinsicArg(String arg, JsonNode root) {
try {
return objectMapper.getNodeFactory().numberNode(Double.parseDouble(arg));
} catch (NumberFormatException e2) {
// fall through: treat as bare path
return resolvePath(arg, root);
// fall through: treat as bare path (may itself be a nested States.* intrinsic)
return resolvePath(arg, root, context);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.github.hectorvent.floci.services.stepfunctions;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Test naming convention mismatch

AGENTS.md specifies that unit tests should follow the *ServiceTest.java naming pattern. Both new test files (AslExecutorArrayParamsTest and AslExecutorIntrinsicContextTest) use a different suffix. While AslExecutor is not technically a service, establishing an inconsistent test-naming pattern here may cause confusion when locating tests with build tooling or running focused test commands like ./mvnw test -Dtest=AslExecutor*.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* A Parameters / ItemSelector payload template must resolve {@code .$} references at ANY nesting
* depth, including inside arrays — e.g. the {@code Environment} array of an ECS
* {@code Overrides.ContainerOverrides[]}. Without array recursion those {@code Value.$} entries pass
* through verbatim and the launched container receives empty environment variables.
*/
class AslExecutorArrayParamsTest {

private final ObjectMapper mapper = new ObjectMapper();

private AslExecutor newExecutor() {
return new AslExecutor(null, null, null, null, null, null, null, null,
mapper, null, null);
}

@Test
void resolvesDotDollarReferencesInsideArrays() throws Exception {
JsonNode parameters = mapper.readTree("""
{
"Overrides": {
"ContainerOverrides": [
{
"Name": "runner",
"Environment": [
{"Name": "ACTION_TYPE", "Value.$": "$.solution.operation"},
{"Name": "PROVISION_TYPE", "Value.$": "$.solution.primaryProvisionType"},
{"Name": "STATIC", "Value": "CREATE_UPDATE"}
]
}
]
}
}
""");
JsonNode input = mapper.readTree("""
{ "solution": { "operation": "create", "primaryProvisionType": "awscdk" } }
""");

JsonNode resolved = newExecutor().resolveParameters(parameters, input, mapper.createObjectNode());

JsonNode env = resolved.path("Overrides").path("ContainerOverrides").path(0).path("Environment");
assertEquals("create", env.path(0).path("Value").asText());
assertEquals("awscdk", env.path(1).path("Value").asText());
assertEquals("CREATE_UPDATE", env.path(2).path("Value").asText());
// The unresolved ".$" key must be gone.
assertTrue(env.path(0).path("Value.$").isMissingNode(), "Value.$ should have been resolved away");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package io.github.hectorvent.floci.services.stepfunctions;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

/**
* Unit tests for Context Object ($$) resolution inside JSONPath-mode intrinsic functions.
*
* <p>A whole-value {@code "x.$": "$$.Map.Item.Value"} Parameters field already resolved against
* the Context Object, but a {@code $$.} reference nested inside an intrinsic argument — e.g.
* {@code States.Format('.../{}', $$.Map.Item.Value.solutionId)} — used to fall through to an
* input-only lookup and resolve to null, because the context was never threaded from
* {@code resolveParameters} into {@code evaluateIntrinsic}/{@code resolveIntrinsicArg}. These
* tests pin the fix and guard the no-context (input-only) path against regression.
*/
class AslExecutorIntrinsicContextTest {

private final ObjectMapper mapper = new ObjectMapper();

/** Only ObjectMapper is exercised by the path/intrinsic resolution methods under test. */
private AslExecutor newExecutor() {
return new AslExecutor(null, null, null, null, null, null, null, null,
mapper, null, null);
}

@Test
void statesFormat_resolvesContextArgInsideParameters() throws Exception {
AslExecutor executor = newExecutor();
// Shaped like a Map ItemSelector: $. args come from the Map state's effective input,
// $$.Map.Item.Value.* comes from the per-iteration Context Object.
JsonNode parameters = mapper.readTree("""
{
"provisionerFolder.$": "States.Format('/mnt/efs/{}/{}/{}/provisioner', $.systemId, $.systemConfigVersion, $$.Map.Item.Value.solutionId)"
}
""");
JsonNode input = mapper.readTree("""
{ "systemId": "SYS1", "systemConfigVersion": "v3" }
""");
JsonNode context = mapper.readTree("""
{ "Map": { "Item": { "Index": 0, "Value": { "solutionId": "SOLUTION_A" } } } }
""");

JsonNode resolved = executor.resolveParameters(parameters, input, context);

assertEquals("/mnt/efs/SYS1/v3/SOLUTION_A/provisioner",
resolved.path("provisionerFolder").asText());
}

@Test
void statesFormat_resolvesContextArgViaResolvePath() {
AslExecutor executor = newExecutor();
JsonNode input = mapper.createObjectNode();
JsonNode context = mapper.createObjectNode();
((com.fasterxml.jackson.databind.node.ObjectNode) context).putObject("Map")
.putObject("Item").putObject("Value").put("solutionId", "SOLUTION_B");

JsonNode result = executor.resolvePath(
"States.Format('id={}', $$.Map.Item.Value.solutionId)", input, context);

assertEquals("id=SOLUTION_B", result.asText());
}

@Test
void wholeContext_resolvesInsideIntrinsic() {
AslExecutor executor = newExecutor();
JsonNode input = mapper.createObjectNode();
JsonNode context = mapper.createObjectNode();
((com.fasterxml.jackson.databind.node.ObjectNode) context).put("token", "abc");

// States.JsonToString($$) serializes the whole Context Object.
JsonNode result = executor.resolvePath("States.JsonToString($$)", input, context);

assertEquals("{\"token\":\"abc\"}", result.asText());
}

@Test
void inputArgsStillResolveWithContextPresent() {
AslExecutor executor = newExecutor();
JsonNode input = mapper.createObjectNode();
((com.fasterxml.jackson.databind.node.ObjectNode) input).put("a", "X").put("b", "Y");
JsonNode context = mapper.createObjectNode();

JsonNode result = executor.resolvePath("States.Format('{}-{}', $.a, $.b)", input, context);

assertEquals("X-Y", result.asText());
}

@Test
void noContext_intrinsicResolutionUnchanged() {
AslExecutor executor = newExecutor();
JsonNode input = mapper.createObjectNode();
((com.fasterxml.jackson.databind.node.ObjectNode) input).put("name", "widget");

// The 2-arg form (context == null) must behave exactly as before: $. args resolve,
// and a stray $$. arg — which has no context to resolve against — yields "null".
assertEquals("widget", executor.resolvePath("States.Format('{}', $.name)", input).asText());
assertEquals("null", executor.resolvePath(
"States.Format('{}', $$.Map.Item.Value.solutionId)", input).asText());
}
}