Skip to content

Commit f4c0a0e

Browse files
committed
feat: support aggregate mock output parameters in deserializer
Disambiguate ConsumedOutputParameter (name + JsonPath value starting with $) from aggregate mock functions (name + static/template value) so the deserializer routes each to the correct field (setMapping vs setValue). Add shared mock integration test proving MCP and REST adapters produce identical payloads from a single aggregate function mock definition.
1 parent 4196ced commit f4c0a0e

File tree

8 files changed

+247
-11
lines changed

8 files changed

+247
-11
lines changed

pom.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
<avro.version>1.11.4</avro.version>
1919
<jetty.version>12.0.25</jetty.version>
2020
<json-smart.version>2.5.2</json-smart.version>
21+
<junit.jupiter.version>5.12.2</junit.jupiter.version>
22+
<jacoco.version>0.8.13</jacoco.version>
2123
</properties>
2224
<distributionManagement>
2325
<repository>
@@ -158,7 +160,7 @@
158160
<dependency>
159161
<groupId>org.junit.jupiter</groupId>
160162
<artifactId>junit-jupiter</artifactId>
161-
<version>6.0.2</version>
163+
<version>${junit.jupiter.version}</version>
162164
<scope>test</scope>
163165
</dependency>
164166
</dependencies>
@@ -195,7 +197,7 @@
195197
<plugin>
196198
<groupId>org.jacoco</groupId>
197199
<artifactId>jacoco-maven-plugin</artifactId>
198-
<version>0.8.11</version>
200+
<version>${jacoco.version}</version>
199201
<executions>
200202
<execution>
201203
<id>prepare-agent</id>

src/main/java/io/naftiko/engine/AggregateRefResolver.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import io.naftiko.spec.exposes.RestServerResourceSpec;
3333
import io.naftiko.spec.exposes.RestServerSpec;
3434
import io.naftiko.spec.exposes.ServerSpec;
35+
import io.naftiko.spec.exposes.StepOutputMappingSpec;
3536

3637
/**
3738
* Resolves aggregate function references ({@code ref}) in adapter units (MCP tools, REST
@@ -136,6 +137,13 @@ void resolveMcpToolRef(McpServerToolSpec tool,
136137
}
137138
}
138139

140+
// Merge step output mappings (function provides default, tool overrides)
141+
if (tool.getMappings().isEmpty() && !function.getMappings().isEmpty()) {
142+
for (StepOutputMappingSpec mapping : function.getMappings()) {
143+
tool.getMappings().add(mapping);
144+
}
145+
}
146+
139147
// Merge inputParameters (function provides default, tool overrides)
140148
if (tool.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) {
141149
for (InputParameterSpec param : function.getInputParameters()) {
@@ -191,6 +199,13 @@ void resolveRestOperationRef(RestServerOperationSpec op,
191199
}
192200
}
193201

202+
// Merge step output mappings
203+
if (op.getMappings().isEmpty() && !function.getMappings().isEmpty()) {
204+
for (StepOutputMappingSpec mapping : function.getMappings()) {
205+
op.getMappings().add(mapping);
206+
}
207+
}
208+
194209
// Merge inputParameters
195210
if (op.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) {
196211
for (InputParameterSpec param : function.getInputParameters()) {

src/main/java/io/naftiko/spec/AggregateFunctionSpec.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.concurrent.ConcurrentHashMap;
1919
import java.util.concurrent.CopyOnWriteArrayList;
2020
import com.fasterxml.jackson.annotation.JsonInclude;
21+
import io.naftiko.spec.exposes.StepOutputMappingSpec;
2122
import io.naftiko.spec.exposes.OperationStepSpec;
2223
import io.naftiko.spec.exposes.ServerCallSpec;
2324

@@ -48,7 +49,7 @@ public class AggregateFunctionSpec {
4849
private final List<OperationStepSpec> steps;
4950

5051
@JsonInclude(JsonInclude.Include.NON_EMPTY)
51-
private final List<Map<String, Object>> mappings;
52+
private final List<StepOutputMappingSpec> mappings;
5253

5354
@JsonInclude(JsonInclude.Include.NON_EMPTY)
5455
private final List<OutputParameterSpec> outputParameters;
@@ -108,7 +109,7 @@ public List<OperationStepSpec> getSteps() {
108109
return steps;
109110
}
110111

111-
public List<Map<String, Object>> getMappings() {
112+
public List<StepOutputMappingSpec> getMappings() {
112113
return mappings;
113114
}
114115

src/main/java/io/naftiko/spec/OutputParameterDeserializer.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import com.fasterxml.jackson.databind.DeserializationContext;
2020
import com.fasterxml.jackson.databind.JsonDeserializer;
2121
import com.fasterxml.jackson.databind.JsonNode;
22-
import com.fasterxml.jackson.databind.node.ObjectNode;
2322

2423
/**
2524
* Custom deserializer for OutputParameterSpec that handles nested structure definitions including
@@ -51,14 +50,20 @@ private OutputParameterSpec deserializeNode(JsonNode node, DeserializationContex
5150

5251
if (node.has("mapping")) {
5352
spec.setMapping(node.get("mapping").asText());
54-
} else if (node.has("value") && node.has("name")) {
55-
// ConsumedOutputParameter uses "value" for JsonPath extraction (has name + value)
56-
spec.setMapping(node.get("value").asText());
5753
}
5854

59-
if (node.has("value") && !node.has("name")) {
60-
// MappedOutputParameter uses "value" for static runtime values (no name)
61-
spec.setValue(node.get("value").asText());
55+
if (node.has("value")) {
56+
String rawValue = node.get("value").asText();
57+
String trimmedValue = rawValue != null ? rawValue.trim() : "";
58+
59+
// ConsumedOutputParameter uses "value" for JsonPath extraction (name + value
60+
// starting with $). Aggregate mock functions also use name + value, but with
61+
// static/template strings — those must stay in setValue().
62+
if (node.has("name") && trimmedValue.startsWith("$") && !node.has("mapping")) {
63+
spec.setMapping(rawValue);
64+
} else {
65+
spec.setValue(rawValue);
66+
}
6267
}
6368

6469
if (node.has("const")) {

src/test/java/io/naftiko/engine/AggregateRefResolverTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import io.naftiko.spec.exposes.McpToolHintsSpec;
3030
import io.naftiko.spec.exposes.RestServerOperationSpec;
3131
import io.naftiko.spec.exposes.ServerCallSpec;
32+
import io.naftiko.spec.exposes.StepOutputMappingSpec;
3233

3334
/**
3435
* Unit tests for AggregateRefResolver — ref resolution, merge semantics, hints derivation.
@@ -195,6 +196,25 @@ void resolveMcpToolRefShouldInheritInputParameters() {
195196
assertEquals("location", tool.getInputParameters().get(0).getName());
196197
}
197198

199+
@Test
200+
void resolveMcpToolRefShouldInheritMappings() {
201+
AggregateFunctionSpec fn = new AggregateFunctionSpec();
202+
fn.setName("get-data");
203+
fn.setDescription("Get data");
204+
fn.getMappings().add(new StepOutputMappingSpec("result", "$.lookup.data"));
205+
206+
Map<String, AggregateFunctionSpec> map = Map.of("data.get-data", fn);
207+
208+
McpServerToolSpec tool = new McpServerToolSpec("get-data", null, "Get data");
209+
tool.setRef("data.get-data");
210+
211+
resolver.resolveMcpToolRef(tool, map);
212+
213+
assertEquals(1, tool.getMappings().size());
214+
assertEquals("result", tool.getMappings().get(0).getTargetName());
215+
assertEquals("$.lookup.data", tool.getMappings().get(0).getValue());
216+
}
217+
198218
@Test
199219
void resolveMcpToolRefShouldInheritDescription() {
200220
AggregateFunctionSpec fn = new AggregateFunctionSpec();
@@ -263,6 +283,25 @@ void resolveRestOperationRefShouldInheritCallFromFunction() {
263283
assertEquals("mock-api.get-data", op.getCall().getOperation());
264284
}
265285

286+
@Test
287+
void resolveRestOperationRefShouldInheritMappings() {
288+
AggregateFunctionSpec fn = new AggregateFunctionSpec();
289+
fn.setName("get-data");
290+
fn.setDescription("Get data");
291+
fn.getMappings().add(new StepOutputMappingSpec("result", "$.lookup.data"));
292+
293+
Map<String, AggregateFunctionSpec> map = Map.of("data.get-data", fn);
294+
295+
RestServerOperationSpec op = new RestServerOperationSpec();
296+
op.setRef("data.get-data");
297+
298+
resolver.resolveRestOperationRef(op, map);
299+
300+
assertEquals(1, op.getMappings().size());
301+
assertEquals("result", op.getMappings().get(0).getTargetName());
302+
assertEquals("$.lookup.data", op.getMappings().get(0).getValue());
303+
}
304+
266305
// ── deriveHints ──
267306

268307
@Test
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Copyright 2025-2026 Naftiko
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5+
* in compliance with the License. You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License
10+
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11+
* or implied. See the License for the specific language governing permissions and limitations under
12+
* the License.
13+
*/
14+
package io.naftiko.engine.exposes.rest;
15+
16+
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertFalse;
18+
import static org.junit.jupiter.api.Assertions.assertTrue;
19+
import java.io.File;
20+
import java.util.Map;
21+
import org.junit.jupiter.api.Test;
22+
import org.restlet.Request;
23+
import org.restlet.Response;
24+
import org.restlet.data.Method;
25+
import org.restlet.data.Status;
26+
import com.fasterxml.jackson.databind.DeserializationFeature;
27+
import com.fasterxml.jackson.databind.JsonNode;
28+
import com.fasterxml.jackson.databind.ObjectMapper;
29+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
30+
import io.naftiko.Capability;
31+
import io.naftiko.engine.exposes.mcp.McpServerAdapter;
32+
import io.naftiko.engine.exposes.mcp.ProtocolDispatcher;
33+
import io.naftiko.spec.NaftikoSpec;
34+
import io.naftiko.spec.exposes.RestServerOperationSpec;
35+
import io.naftiko.spec.exposes.RestServerSpec;
36+
37+
/**
38+
* Integration test proving that a single aggregate function mock can be reused unchanged by
39+
* both MCP and REST adapters.
40+
*/
41+
public class AggregateSharedMockIntegrationTest {
42+
43+
private static final ObjectMapper JSON = new ObjectMapper();
44+
45+
@Test
46+
void aggregateMockShouldReturnSamePayloadForMcpAndRest() throws Exception {
47+
Capability capability = loadCapability("src/test/resources/aggregates/aggregate-shared-mock.yaml");
48+
49+
McpServerAdapter mcpAdapter = (McpServerAdapter) capability.getServerAdapters().get(0);
50+
ProtocolDispatcher dispatcher = new ProtocolDispatcher(mcpAdapter);
51+
52+
JsonNode mcpResponse = dispatcher.dispatch(JSON.readTree(
53+
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," +
54+
"\"params\":{\"name\":\"hello\",\"arguments\":{\"name\":\"Nina\"}}}"));
55+
56+
assertFalse(mcpResponse.path("result").path("isError").asBoolean(),
57+
"MCP tools/call should not fail for aggregate mock ref");
58+
59+
JsonNode mcpPayload = JSON.readTree(mcpResponse.path("result").path("content")
60+
.get(0).path("text").asText());
61+
62+
RestServerAdapter restAdapter = (RestServerAdapter) capability.getServerAdapters().get(1);
63+
RestServerSpec restSpec = (RestServerSpec) restAdapter.getSpec();
64+
ResourceRestlet restlet = new ResourceRestlet(capability, restSpec,
65+
restSpec.getResources().get(0));
66+
RestServerOperationSpec restOperation = restSpec.getResources().get(0).getOperations().get(0);
67+
68+
assertEquals(2, restOperation.getOutputParameters().size(),
69+
"REST operation should inherit two aggregate output parameters");
70+
71+
assertTrue(restlet.canBuildMockResponse(restOperation),
72+
"REST operation should inherit aggregate mock output parameters");
73+
74+
Request request = new Request(Method.GET, "http://localhost/hello?name=Nina");
75+
Response response = new Response(request);
76+
restlet.sendMockResponse(restOperation, response, Map.of("name", "Nina"));
77+
78+
assertEquals(Status.SUCCESS_OK, response.getStatus());
79+
JsonNode restPayload = JSON.readTree(response.getEntity().getText());
80+
81+
assertEquals("Hello, Nina!", mcpPayload.path("message").asText());
82+
assertEquals("aggregate-mock", mcpPayload.path("source").asText());
83+
assertEquals(mcpPayload, restPayload,
84+
"MCP and REST should share the same aggregate mock output");
85+
}
86+
87+
private Capability loadCapability(String path) throws Exception {
88+
File file = new File(path);
89+
ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
90+
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
91+
NaftikoSpec spec = mapper.readValue(file, NaftikoSpec.class);
92+
return new Capability(spec);
93+
}
94+
}

src/test/java/io/naftiko/spec/OutputParameterDeserializationTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ public void testConsumedOutputParameterUsesValueField() throws Exception {
9999
assertEquals("userid", spec.getName(), "Name should be parsed");
100100
assertEquals("string", spec.getType(), "Type should be parsed");
101101
assertEquals("$.id", spec.getMapping(), "Value alias should populate mapping");
102+
assertNull(spec.getValue(), "Consumed output JsonPath alias should not populate value");
103+
}
104+
105+
@Test
106+
public void testNamedMockOutputParameterShouldKeepStaticValue() throws Exception {
107+
String yamlSnippet = """
108+
name: message
109+
type: string
110+
value: "Hello, {{name}}!"
111+
""";
112+
113+
ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
114+
OutputParameterSpec spec = mapper.readValue(yamlSnippet, OutputParameterSpec.class);
115+
116+
assertEquals("message", spec.getName(), "Name should be parsed");
117+
assertEquals("string", spec.getType(), "Type should be parsed");
118+
assertEquals("Hello, {{name}}!", spec.getValue(),
119+
"Named mock output should preserve static/template value");
120+
assertNull(spec.getMapping(), "Named mock output should not be re-routed to mapping");
102121
}
103122

104123
@Test
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json
2+
---
3+
naftiko: "1.0.0-alpha1"
4+
info:
5+
label: "Aggregate Shared Mock Test"
6+
description: "Shared aggregate mock function consumed by MCP and REST refs"
7+
tags:
8+
- Test
9+
- Aggregate
10+
created: "2026-04-09"
11+
modified: "2026-04-09"
12+
13+
capability:
14+
aggregates:
15+
- label: "Greeting"
16+
namespace: "greeting"
17+
functions:
18+
- name: "hello"
19+
description: "Builds a greeting payload from input parameters."
20+
inputParameters:
21+
- name: "name"
22+
type: "string"
23+
description: "Name to greet"
24+
outputParameters:
25+
- name: "message"
26+
type: "string"
27+
value: "Hello, {{name}}!"
28+
- name: "source"
29+
type: "string"
30+
value: "aggregate-mock"
31+
32+
exposes:
33+
- type: "mcp"
34+
address: "localhost"
35+
port: 9200
36+
namespace: "greeting-mcp"
37+
description: "MCP adapter using aggregate ref in mock mode."
38+
tools:
39+
- name: "hello"
40+
description: "Return greeting payload."
41+
ref: "greeting.hello"
42+
43+
- type: "rest"
44+
address: "localhost"
45+
port: 9201
46+
namespace: "greeting-rest"
47+
resources:
48+
- path: "/hello"
49+
name: "hello"
50+
description: "Greeting resource"
51+
operations:
52+
- method: "GET"
53+
name: "hello"
54+
ref: "greeting.hello"
55+
inputParameters:
56+
- name: "name"
57+
in: "query"
58+
type: "string"
59+
description: "Name to greet"
60+
61+
consumes: []

0 commit comments

Comments
 (0)