Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/main/java/io/naftiko/engine/aggregates/Aggregate.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public Aggregate(AggregateSpec spec, OperationStepExecutor stepExecutor) {
this.namespace = spec.getNamespace();
this.functions = new CopyOnWriteArrayList<>();
for (AggregateFunctionSpec fnSpec : spec.getFunctions()) {
this.functions.add(new AggregateFunction(fnSpec, stepExecutor));
this.functions.add(new AggregateFunction(fnSpec, stepExecutor, this.namespace));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ public class AggregateFunction {

private final AggregateFunctionSpec spec;
private final OperationStepExecutor stepExecutor;
private final String namespace;

AggregateFunction(AggregateFunctionSpec spec, OperationStepExecutor stepExecutor) {
AggregateFunction(AggregateFunctionSpec spec, OperationStepExecutor stepExecutor,
String namespace) {
this.spec = spec;
this.stepExecutor = stepExecutor;
this.namespace = namespace;
}

public String getName() {
Expand Down Expand Up @@ -101,7 +104,7 @@ public FunctionResult execute(Map<String, Object> parameters) throws Exception {
}

// Merge function-level 'with' parameters
OperationStepExecutor.mergeWithParameters(spec.getWith(), merged, null);
OperationStepExecutor.mergeWithParameters(spec.getWith(), merged, namespace);

boolean hasCall = spec.getCall() != null;
boolean isOrchestrated = spec.getSteps() != null && !spec.getSteps().isEmpty();
Expand All @@ -113,6 +116,9 @@ public FunctionResult execute(Map<String, Object> parameters) throws Exception {
return new FunctionResult(null, null, mockRoot);
}

// Covers both orchestrated and simple-call paths
stepExecutor.setExposeNamespace(namespace);

// Orchestrated mode
if (isOrchestrated) {
OperationStepExecutor.StepExecutionResult stepResult =
Expand Down
23 changes: 21 additions & 2 deletions src/main/java/io/naftiko/engine/exposes/OperationStepExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,29 @@ public class OperationStepExecutor {

private final Capability capability;
private final ObjectMapper mapper;
private volatile String exposeNamespace;
Comment thread
jlouvel marked this conversation as resolved.

public OperationStepExecutor(Capability capability) {
this(capability, null);
}

public OperationStepExecutor(Capability capability, String exposeNamespace) {
this.capability = capability;
this.mapper = new ObjectMapper();
this.exposeNamespace = exposeNamespace;
}

/**
* Override the expose namespace after construction.
*
* <p>This setter exists for {@link io.naftiko.engine.aggregates.AggregateFunction}, which
* shares a single {@code OperationStepExecutor} across multiple aggregate functions that may
* belong to different namespaces. The function sets its own namespace before each execution.
* Handlers whose namespace is known at construction time should use
* {@link #OperationStepExecutor(Capability, String)} instead.</p>
*/
public void setExposeNamespace(String exposeNamespace) {
this.exposeNamespace = exposeNamespace;
}

/**
Expand Down Expand Up @@ -280,7 +299,7 @@ private HandlingContext executeCallStep(OperationStepCallSpec callStep,
// Merge step-level 'with' parameters with base parameters
Map<String, Object> stepParams = new ConcurrentHashMap<>(baseParameters);

mergeWithParameters(callStep.getWith(), stepParams, null);
mergeWithParameters(callStep.getWith(), stepParams, exposeNamespace);

if (callStep.getCall() != null) {
String[] tokens = callStep.getCall().split("\\.");
Expand Down Expand Up @@ -368,7 +387,7 @@ public HandlingContext findClientRequestFor(ServerCallSpec call,
merged.putAll(requestParams);
}

mergeWithParameters(call.getWith(), merged, null);
mergeWithParameters(call.getWith(), merged, exposeNamespace);

if (call.getOperation() != null) {
String[] tokens = call.getOperation().split("\\.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public ResourceHandler(Capability capability, List<McpServerResourceSpec> resour
String namespace) {
this.capability = capability;
this.resourceSpecs = new ArrayList<>(resources);
this.stepExecutor = new OperationStepExecutor(capability);
this.stepExecutor = new OperationStepExecutor(capability, namespace);
this.namespace = namespace;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public ToolHandler(Capability capability, List<McpServerToolSpec> tools,
String exposeNamespace) {
this.capability = capability;
this.toolSpecs = new ConcurrentHashMap<>();
this.stepExecutor = new OperationStepExecutor(capability);
this.stepExecutor = new OperationStepExecutor(capability, exposeNamespace);
this.exposeNamespace = exposeNamespace;

for (McpServerToolSpec tool : tools) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public ResourceRestlet(Capability capability, RestServerSpec serverSpec,
this.capability = capability;
this.serverSpec = serverSpec;
this.resourceSpec = resourceSpec;
this.stepExecutor = new OperationStepExecutor(capability);
this.stepExecutor = new OperationStepExecutor(capability, serverSpec.getNamespace());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright 2025-2026 Naftiko
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package io.naftiko.engine.aggregates;

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

import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import io.naftiko.spec.AggregateFunctionSpec;
import io.naftiko.spec.OutputParameterSpec;

/**
* Unit tests for {@link AggregateFunction} — namespace-qualified reference resolution
* in function-level {@code with} blocks.
*/
public class AggregateFunctionTest {

/**
* When a mock-mode function has {@code with: {voyage-id: "shipyard.voyage-id"}} and the
* aggregate namespace is "shipyard", calling execute should resolve the qualified reference
* to the caller's argument and use it for mock data output.
*
* Before the fix: namespace was always {@code null} in mergeWithParameters, so the literal
* "shipyard.voyage-id" overwrote the caller's "VOY-2026-042" and appeared in the mock output.
*/
@Test
void executeShouldResolveNamespaceQualifiedReferencesInFunctionWith() throws Exception {
AggregateFunctionSpec spec = new AggregateFunctionSpec();
spec.setName("get-voyage");
spec.setDescription("Get a voyage manifest.");
Map<String, Object> withBlock = new HashMap<>();
withBlock.put("voyage-id", "shipyard.voyage-id");
spec.setWith(withBlock);

// Add an output parameter using Mustache to echo the resolved value
OutputParameterSpec outParam = new OutputParameterSpec();
outParam.setName("voyage-id");
outParam.setType("string");
outParam.setValue("{{voyage-id}}");
spec.getOutputParameters().add(outParam);

// No call, no steps -> mock mode
AggregateFunction fn = new AggregateFunction(spec, null, "shipyard");

Map<String, Object> callerParams = new HashMap<>();
callerParams.put("voyage-id", "VOY-2026-042");
FunctionResult result = fn.execute(callerParams);

assertTrue(result.isMock(), "Should be mock mode");
assertNotNull(result.mockOutput, "Mock output should not be null");

String outputValue = result.mockOutput.get("voyage-id").asText();
assertEquals("VOY-2026-042", outputValue,
"Namespace-qualified 'with' should resolve to caller's argument, "
+ "not the literal 'shipyard.voyage-id'");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Copyright 2025-2026 Naftiko
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package io.naftiko.engine.aggregates;

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

import java.io.File;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.naftiko.Capability;
import io.naftiko.engine.exposes.OperationStepExecutor;
import io.naftiko.spec.AggregateSpec;
import io.naftiko.spec.NaftikoSpec;

/**
* Integration test for issue #290: namespace-qualified references in aggregate function steps.
*
* Loads a capability YAML where an aggregate function has a step with
* {@code with: {voyage-id: shipyard.voyage-id}} and verifies that executing the function
* resolves the namespace-qualified reference before building the HTTP request URL.
*
* Uses a parameter-capturing executor to observe what parameters are passed to the consumed
* operation, independent of HTTP transport success/failure.
*
* Before the fix: the aggregate namespace was not propagated to the step executor, so the
* literal "shipyard.voyage-id" appeared in the URL instead of the resolved value.
*/
public class AggregateStepNamespaceIntegrationTest {

private Capability capability;
private NaftikoSpec spec;

@BeforeEach
void setUp() throws Exception {
String path = "src/test/resources/aggregates/aggregate-step-with-namespace.yaml";
File file = new File(path);
assertTrue(file.exists(), "Test capability file should exist at " + path);

ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
spec = mapper.readValue(file, NaftikoSpec.class);
capability = new Capability(spec);
}

/**
* Execute the aggregate function and verify the step-level namespace-qualified reference
* resolves correctly. A CapturingStepExecutor captures the resolved parameters before the
* HTTP call, so the assertion does not depend on error message contents.
*/
@Test
void aggregateFunctionExecuteShouldResolveNamespaceQualifiedWithInSteps() {
CapturingStepExecutor executor = new CapturingStepExecutor(capability);

AggregateSpec aggregateSpec = spec.getCapability().getAggregates().get(0);
Aggregate aggregate = new Aggregate(aggregateSpec, executor);
AggregateFunction fn = aggregate.findFunction("get-voyage-manifest");
assertNotNull(fn, "Aggregate function should be found");

Map<String, Object> params = new HashMap<>();
params.put("voyage-id", "VOY-2026-042");

try {
fn.execute(params);
} catch (Exception expected) {
// HTTP connection failure expected — no server on port 19999
}

assertNotNull(executor.capturedParams,
"Parameters should have been captured before HTTP call");
assertEquals("VOY-2026-042", executor.capturedParams.get("voyage-id"),
"Namespace-qualified reference 'shipyard.voyage-id' should resolve to "
+ "the caller's argument, not be passed as literal");
}

/**
* Test-only subclass that captures parameters at the HTTP request construction point.
*/
static class CapturingStepExecutor extends OperationStepExecutor {
Map<String, Object> capturedParams;

CapturingStepExecutor(Capability capability) {
super(capability);
}

@Override
public HandlingContext findClientRequestFor(String clientNamespace, String clientOpName,
Map<String, Object> parameters) {
capturedParams = new HashMap<>(parameters);
return super.findClientRequestFor(clientNamespace, clientOpName, parameters);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package io.naftiko.engine.exposes;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
Expand Down Expand Up @@ -279,6 +280,85 @@ void executeStepsShouldFailForCallStepWithoutValidCallReference() throws Excepti
assertEquals("Invalid call format: null", error.getMessage());
}

/**
* Regression test for #290: namespace-qualified references in step-level WithInjector.
*
* When a step has {@code with: {voyageId: "shipyard-tools.voyageId"}}, the qualified reference
* should be resolved to the caller's argument value "VOY-2026-042", not passed as a literal.
*
* This test captures the parameters actually passed to findClientRequestFor. Before the fix,
* the namespace is not propagated to step execution, so the literal string
* "shipyard-tools.voyageId" is used instead of the resolved value.
*/
@Test
void executeStepsShouldResolveNamespaceQualifiedReferencesInStepWith() throws Exception {
Capability capability = capabilityFromYaml("""
naftiko: "%s"
capability:
exposes:
- type: "rest"
address: "localhost"
port: 0
namespace: "test"
resources:
- path: "/x"
operations:
- method: "GET"
name: "x"
consumes:
- type: "http"
namespace: "registry"
baseUri: "http://localhost:19999"
resources:
- path: "/voyages/{{voyageId}}"
name: "voyage"
operations:
- method: "GET"
name: "get-voyage"
inputParameters:
- name: "voyageId"
in: "path"
""".formatted(schemaVersion));

// Subclass that captures parameters at the point of HTTP request construction
class CapturingExecutor extends OperationStepExecutor {
Map<String, Object> capturedParams;

CapturingExecutor(Capability cap) {
super(cap);
}

@Override
public HandlingContext findClientRequestFor(String clientNamespace,
String clientOpName, Map<String, Object> parameters) {
capturedParams = new HashMap<>(parameters);
return super.findClientRequestFor(clientNamespace, clientOpName, parameters);
}
}

CapturingExecutor executor = new CapturingExecutor(capability);
executor.setExposeNamespace("shipyard-tools");

OperationStepCallSpec step = new OperationStepCallSpec();
step.setType("call");
step.setName("get-voyage");
step.setCall("registry.get-voyage");
step.setWith(Map.of("voyageId", "shipyard-tools.voyageId"));

// The HTTP call will fail (no server), but parameters are captured before the call
try {
executor.executeSteps(List.of(step), Map.of("voyageId", "VOY-2026-042"));
} catch (RuntimeException expected) {
// HTTP connection failure expected
}

assertNotNull(executor.capturedParams,
"Parameters should have been captured before HTTP call");
assertEquals("VOY-2026-042", executor.capturedParams.get("voyageId"),
"Namespace-qualified reference 'shipyard-tools.voyageId' should resolve to "
+ "the caller's argument value, not be passed as a literal string");
}

private static OperationStepExecutor executorFromYaml(String yaml) throws Exception {
return new OperationStepExecutor(capabilityFromYaml(yaml));
}
Expand Down
Loading
Loading