diff --git a/src/main/java/io/naftiko/engine/LookupExecutor.java b/src/main/java/io/naftiko/engine/LookupExecutor.java new file mode 100644 index 0000000..06c47f5 --- /dev/null +++ b/src/main/java/io/naftiko/engine/LookupExecutor.java @@ -0,0 +1,158 @@ +/** + * 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; + +import java.util.Iterator; +import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Executor for lookup step operations. + * + * Handles cross-reference matching and field extraction from step outputs. + */ +public class LookupExecutor { + + /** + * Execute a lookup operation against a previous step's output. + * + * Performs matching based on a key field and returns matching entries + * with only the specified output fields. + * + * @param indexData The output data from the previous (index) step + * @param matchField The field name to match against (e.g., "email") + * @param lookupValue The value to match (typically a JsonPath-resolved value) + * @param outputFields Fields to extract from the matched entry + * @return A JsonNode containing the matched entry with only the specified fields, + * or null if no match found + */ + public static JsonNode executeLookup(JsonNode indexData, String matchField, + String lookupValue, List outputFields) { + + if (indexData == null || matchField == null || lookupValue == null) { + return null; + } + + // Handle array of entries (most common case) + if (indexData.isArray()) { + return lookupInArray(indexData, matchField, lookupValue, outputFields); + } + + // Handle single object + if (indexData.isObject()) { + return lookupInObject(indexData, matchField, lookupValue, outputFields); + } + + return null; + } + + /** + * Perform lookup in an array of objects/entries. + */ + private static JsonNode lookupInArray(JsonNode arrayNode, String matchField, + String lookupValue, List outputFields) { + + Iterator elements = arrayNode.elements(); + while (elements.hasNext()) { + JsonNode entry = elements.next(); + + if (entry.isObject()) { + JsonNode matchNodeValue = entry.get(matchField); + + // Compare match field value with lookup value + if (matchNodeValue != null && + lookupValue.equals(matchNodeValue.asText())) { + + // Found a match - extract specified fields + return extractFields(entry, outputFields); + } + } + } + + return null; // No match found + } + + /** + * Perform lookup in a single object. + */ + private static JsonNode lookupInObject(JsonNode objNode, String matchField, + String lookupValue, List outputFields) { + + JsonNode matchNodeValue = objNode.get(matchField); + + if (matchNodeValue != null && + lookupValue.equals(matchNodeValue.asText())) { + + return extractFields(objNode, outputFields); + } + + return null; // No match + } + + /** + * Extract specified fields from an entry. + */ + private static ObjectNode extractFields(JsonNode entry, List fieldNames) { + if (entry == null || !entry.isObject() || fieldNames == null || fieldNames.isEmpty()) { + return null; + } + + ObjectNode result = JsonNodeFactory.instance.objectNode(); + + for (String fieldName : fieldNames) { + JsonNode fieldValue = entry.get(fieldName); + if (fieldValue != null) { + result.set(fieldName, fieldValue); + } else { + result.putNull(fieldName); + } + } + + return result; + } + + /** + * Merge lookup result into a context map for use in subsequent steps. + * + * @param result The lookup result (an ObjectNode with extracted fields) + * @param targetMap The map to merge the result into (output of the lookup step) + */ + public static void mergeLookupResult(JsonNode result, java.util.Map targetMap) { + if (result == null || !result.isObject() || targetMap == null) { + return; + } + + Iterator fieldNames = result.fieldNames(); + while (fieldNames.hasNext()) { + String fieldName = fieldNames.next(); + JsonNode fieldValue = result.get(fieldName); + + if (fieldValue.isNull()) { + targetMap.put(fieldName, null); + } else if (fieldValue.isTextual()) { + targetMap.put(fieldName, fieldValue.asText()); + } else if (fieldValue.isNumber()) { + targetMap.put(fieldName, fieldValue.numberValue()); + } else if (fieldValue.isBoolean()) { + targetMap.put(fieldName, fieldValue.asBoolean()); + } else { + // For complex types, keep as JsonNode + targetMap.put(fieldName, fieldValue); + } + } + } + +} diff --git a/src/main/java/io/naftiko/engine/StepExecutionContext.java b/src/main/java/io/naftiko/engine/StepExecutionContext.java new file mode 100644 index 0000000..fb811e1 --- /dev/null +++ b/src/main/java/io/naftiko/engine/StepExecutionContext.java @@ -0,0 +1,86 @@ +/** + * 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; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Execution context for orchestrated operation steps. + * + * Maintains the state of executed steps, including their outputs, allowing + * subsequent lookup steps to reference and cross-reference previous results. + */ +public class StepExecutionContext { + + /** + * Maps step names to their execution outputs (as JsonNode objects) + */ + private final Map stepOutputs; + + public StepExecutionContext() { + this.stepOutputs = new ConcurrentHashMap<>(); + } + + /** + * Store the output of a completed step for later reference. + * + * @param stepName The name of the step (as declared in step definition) + * @param output The output result of the step execution + */ + public void storeStepOutput(String stepName, JsonNode output) { + if (stepName != null && output != null) { + stepOutputs.put(stepName, output); + } + } + + /** + * Retrieve the output of a previously executed step. + * + * @param stepName The name of the step to retrieve + * @return The JsonNode output of the step, or null if not found + */ + public JsonNode getStepOutput(String stepName) { + return stepOutputs.get(stepName); + } + + /** + * Check if a step has been executed. + * + * @param stepName The name of the step to check + * @return true if the step output is available + */ + public boolean hasStepOutput(String stepName) { + return stepOutputs.containsKey(stepName); + } + + /** + * Get all stored step outputs. + * + * @return An immutable view of all step outputs + */ + public Map getAllStepOutputs() { + return new HashMap<>(stepOutputs); + } + + /** + * Clear all stored step outputs. + */ + public void clear() { + stepOutputs.clear(); + } + +} diff --git a/src/main/java/io/naftiko/engine/exposes/ApiResourceRestlet.java b/src/main/java/io/naftiko/engine/exposes/ApiResourceRestlet.java index f0565b6..b19d920 100644 --- a/src/main/java/io/naftiko/engine/exposes/ApiResourceRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/ApiResourceRestlet.java @@ -22,18 +22,22 @@ import org.restlet.data.Status; import io.naftiko.Capability; import io.naftiko.engine.Converter; +import io.naftiko.engine.LookupExecutor; import io.naftiko.engine.Resolver; +import io.naftiko.engine.StepExecutionContext; import io.naftiko.engine.consumes.ClientAdapter; import io.naftiko.engine.consumes.HttpClientAdapter; import io.naftiko.spec.InputParameterSpec; import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.consumes.HttpClientOperationSpec; import io.naftiko.spec.exposes.ApiServerCallSpec; +import io.naftiko.spec.exposes.OperationStepCallSpec; +import io.naftiko.spec.exposes.OperationStepLookupSpec; import io.naftiko.spec.exposes.ApiServerForwardSpec; import io.naftiko.spec.exposes.ApiServerOperationSpec; import io.naftiko.spec.exposes.ApiServerResourceSpec; import io.naftiko.spec.exposes.ApiServerSpec; -import io.naftiko.spec.exposes.ApiServerStepSpec; +import io.naftiko.spec.exposes.OperationStepSpec; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.NullNode; @@ -134,47 +138,93 @@ private boolean handleFromOperationSpec(Request request, Response response) { return true; } } else { - for (ApiServerStepSpec step : serverOp.getSteps()) { - // Merge step-level 'with' parameters if present - Map stepParams = new ConcurrentHashMap<>(inputParameters); - - // First merge step-level 'with' parameters - if (step.getWith() != null) { - stepParams.putAll(step.getWith()); - } - - // Then merge call-level 'with' parameters (call level takes precedence) - if (step.getCall() != null && step.getCall().getWith() != null) { - stepParams.putAll(step.getCall().getWith()); - } - - try { - found = findClientRequestFor(step.getCall(), stepParams); - } catch (IllegalArgumentException e) { - response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); - response.setEntity( - "Error resolving request parameters: " + e.getMessage(), - MediaType.TEXT_PLAIN); - return true; - } + // Orchestrated mode - execute steps in sequence + StepExecutionContext stepContext = new StepExecutionContext(); + ObjectMapper mapper = new ObjectMapper(); + + for (OperationStepSpec step : serverOp.getSteps()) { + if (step instanceof OperationStepCallSpec) { + OperationStepCallSpec callStep = (OperationStepCallSpec) step; + // Merge step-level 'with' parameters if present + Map stepParams = new ConcurrentHashMap<>(inputParameters); + + // First merge step-level 'with' parameters + if (callStep.getWith() != null) { + stepParams.putAll(callStep.getWith()); + } - if (found != null) { try { - // Send the request to the target endpoint - found.handle(); - } catch (Exception e) { - response.setStatus(Status.SERVER_ERROR_INTERNAL); - response.setEntity("Error while handling an HTTP client call\n\n" - + e.toString(), MediaType.TEXT_PLAIN); + if (callStep.getCall() != null) { + String[] tokens = callStep.getCall().split("\\."); + if (tokens.length == 2) { + found = findClientRequestFor(tokens[0], tokens[1], stepParams); + } + } + } catch (IllegalArgumentException e) { + response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + response.setEntity( + "Error resolving request parameters: " + e.getMessage(), + MediaType.TEXT_PLAIN); return true; } - } else { - response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); - response.setEntity("Invalid call format: " - + (step.getCall() != null ? step.getCall().getOperation() - : "null"), - MediaType.TEXT_PLAIN); - return true; + + if (found != null) { + try { + // Send the request to the target endpoint + found.handle(); + + // Store the call step output for lookup references + if (found.clientResponse != null && found.clientResponse.getEntity() != null) { + try { + JsonNode stepOutput = mapper.readTree(found.clientResponse.getEntity().getText()); + stepContext.storeStepOutput(callStep.getName(), stepOutput); + } catch (Exception ignoreJsonParseError) { + // If response is not JSON, store as null + } + } + } catch (Exception e) { + response.setStatus(Status.SERVER_ERROR_INTERNAL); + response.setEntity("Error while handling an HTTP client call\n\n" + + e.toString(), MediaType.TEXT_PLAIN); + return true; + } + } else { + response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + response.setEntity("Invalid call format: " + + (callStep.getCall() != null ? callStep.getCall() : "null"), + MediaType.TEXT_PLAIN); + return true; + } + } else if (step instanceof OperationStepLookupSpec) { + OperationStepLookupSpec lookupStep = (OperationStepLookupSpec) step; + + // Get the output from the previous (index) step + JsonNode indexData = stepContext.getStepOutput(lookupStep.getIndex()); + if (indexData == null) { + response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + response.setEntity("Lookup step references non-existent step: " + lookupStep.getIndex(), + MediaType.TEXT_PLAIN); + return true; + } + + // Resolve the lookup value (may contain template expressions) + String resolvedLookupValue = Resolver.resolveMustacheTemplate( + lookupStep.getLookupValue(), inputParameters); + + // Execute lookup operation + JsonNode lookupResult = LookupExecutor.executeLookup( + indexData, + lookupStep.getMatch(), + resolvedLookupValue, + lookupStep.getOutputParameters()); + + if (lookupResult != null) { + // Store lookup result for subsequent references + stepContext.storeStepOutput(lookupStep.getName(), lookupResult); + } else { + // Lookup returned no match - store null + stepContext.storeStepOutput(lookupStep.getName(), null); + } } } diff --git a/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java b/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java index 4aaca82..ba391a5 100644 --- a/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java +++ b/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java @@ -34,7 +34,9 @@ import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.consumes.HttpClientOperationSpec; import io.naftiko.spec.exposes.ApiServerCallSpec; -import io.naftiko.spec.exposes.ApiServerStepSpec; +import io.naftiko.spec.exposes.OperationStepSpec; +import io.naftiko.spec.exposes.OperationStepCallSpec; +import io.naftiko.spec.exposes.OperationStepLookupSpec; import io.naftiko.spec.exposes.McpServerToolSpec; /** @@ -106,26 +108,34 @@ public McpSchema.CallToolResult handleToolCall(String toolName, Map stepParams = new ConcurrentHashMap<>(parameters); - - // Merge step-level 'with' parameters - if (step.getWith() != null) { - stepParams.putAll(step.getWith()); - } - - // Merge call-level 'with' parameters (call level takes precedence) - if (step.getCall() != null && step.getCall().getWith() != null) { - stepParams.putAll(step.getCall().getWith()); - } + for (OperationStepSpec step : toolSpec.getSteps()) { + if (step instanceof OperationStepCallSpec) { + OperationStepCallSpec callStep = (OperationStepCallSpec) step; + Map stepParams = new ConcurrentHashMap<>(parameters); + + // Merge step-level 'with' parameters + if (callStep.getWith() != null) { + stepParams.putAll(callStep.getWith()); + } - found = findClientRequestFor(step.getCall(), stepParams); + if (callStep.getCall() != null) { + String[] tokens = callStep.getCall().split("\\."); + if (tokens.length == 2) { + found = findClientRequestFor(tokens[0], tokens[1], stepParams); + } + } - if (found != null) { - found.handle(); + if (found != null) { + found.handle(); + } else { + throw new IllegalArgumentException("Invalid call format in step: " + + (callStep.getCall() != null ? callStep.getCall() : "null")); + } + } else if (step instanceof OperationStepLookupSpec) { + // Lookup steps will be handled in a future implementation + throw new UnsupportedOperationException("Lookup steps are not yet supported in MCP tools"); } else { - throw new IllegalArgumentException("Invalid call format in step: " - + (step.getCall() != null ? step.getCall().getOperation() : "null")); + throw new IllegalArgumentException("Unknown step type: " + step.getClass().getName()); } } } else { diff --git a/src/main/java/io/naftiko/spec/exposes/ApiServerOperationSpec.java b/src/main/java/io/naftiko/spec/exposes/ApiServerOperationSpec.java index 9d3f9ae..8948eb8 100644 --- a/src/main/java/io/naftiko/spec/exposes/ApiServerOperationSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/ApiServerOperationSpec.java @@ -29,7 +29,7 @@ public class ApiServerOperationSpec extends OperationSpec { private volatile ApiServerCallSpec call; @JsonInclude(JsonInclude.Include.NON_EMPTY) - private final List steps; + private final List steps; @JsonInclude(JsonInclude.Include.NON_NULL) private volatile Map with; @@ -57,7 +57,7 @@ public ApiServerOperationSpec(ApiServerResourceSpec parentResource, String metho this.steps = new CopyOnWriteArrayList<>(); } - public List getSteps() { + public List getSteps() { return steps; } diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java index 28a92a3..b2d5d71 100644 --- a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java @@ -43,7 +43,7 @@ public class McpServerToolSpec { private volatile Map with; @JsonInclude(JsonInclude.Include.NON_EMPTY) - private final List steps; + private final List steps; @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List outputParameters; @@ -96,7 +96,7 @@ public void setWith(Map with) { this.with = with != null ? new ConcurrentHashMap<>(with) : null; } - public List getSteps() { + public List getSteps() { return steps; } diff --git a/src/main/java/io/naftiko/spec/exposes/OperationStepCallSpec.java b/src/main/java/io/naftiko/spec/exposes/OperationStepCallSpec.java new file mode 100644 index 0000000..25b7993 --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/OperationStepCallSpec.java @@ -0,0 +1,70 @@ +/** + * 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.spec.exposes; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Operation Step Call Specification Element + * + * Represents a call to a consumed operation within an orchestration step. + * Includes the operation reference and optional parameter injection via WithInjector. + */ +public class OperationStepCallSpec extends OperationStepSpec { + + @JsonProperty("call") + private volatile String call; + + @JsonProperty("with") + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile Map with; + + public OperationStepCallSpec() { + this(null, null, null, null); + } + + public OperationStepCallSpec(String name, String call) { + this(name, call, null); + } + + public OperationStepCallSpec(String name, String call, Map with) { + this("call", name, call, with); + } + + public OperationStepCallSpec(String type, String name, String call, Map with) { + super(type, name); + this.call = call; + this.with = with != null ? new ConcurrentHashMap<>(with) : null; + } + + public String getCall() { + return call; + } + + public void setCall(String call) { + this.call = call; + } + + public Map getWith() { + return with; + } + + public void setWith(Map with) { + this.with = with != null ? new ConcurrentHashMap<>(with) : null; + } + +} diff --git a/src/main/java/io/naftiko/spec/exposes/OperationStepLookupSpec.java b/src/main/java/io/naftiko/spec/exposes/OperationStepLookupSpec.java new file mode 100644 index 0000000..e714bd2 --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/OperationStepLookupSpec.java @@ -0,0 +1,93 @@ +/** + * 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.spec.exposes; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Operation Step Lookup Specification Element + * + * Represents a lookup operation that cross-references the output of a previous call step. + * Performs value matching and extraction within an orchestration step. + */ +public class OperationStepLookupSpec extends OperationStepSpec { + + @JsonProperty("index") + private volatile String index; + + @JsonProperty("match") + private volatile String match; + + @JsonProperty("lookupValue") + private volatile String lookupValue; + + @JsonProperty("outputParameters") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List outputParameters; + + public OperationStepLookupSpec() { + this(null, null, null, null, null); + } + + public OperationStepLookupSpec(String name, String index, String match, String lookupValue) { + this(name, index, match, lookupValue, null); + } + + public OperationStepLookupSpec(String name, String index, String match, String lookupValue, List outputParameters) { + this("lookup", name, index, match, lookupValue, outputParameters); + } + + public OperationStepLookupSpec(String type, String name, String index, String match, String lookupValue, List outputParameters) { + super(type, name); + this.index = index; + this.match = match; + this.lookupValue = lookupValue; + this.outputParameters = new CopyOnWriteArrayList<>(); + if (outputParameters != null) { + this.outputParameters.addAll(outputParameters); + } + } + + public String getIndex() { + return index; + } + + public void setIndex(String index) { + this.index = index; + } + + public String getMatch() { + return match; + } + + public void setMatch(String match) { + this.match = match; + } + + public String getLookupValue() { + return lookupValue; + } + + public void setLookupValue(String lookupValue) { + this.lookupValue = lookupValue; + } + + public List getOutputParameters() { + return outputParameters; + } + +} diff --git a/src/main/java/io/naftiko/spec/exposes/OperationStepSpec.java b/src/main/java/io/naftiko/spec/exposes/OperationStepSpec.java new file mode 100644 index 0000000..d4a1cee --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/OperationStepSpec.java @@ -0,0 +1,69 @@ +/** + * 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.spec.exposes; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Base Operation Step Specification Element + * + * Represents a step in an orchestrated operation. OperationStep is a discriminated union + * with two subtypes: OperationStepCall and OperationStepLookup. + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", + visible = true // Make type visible for both deserialization and serialization +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = OperationStepCallSpec.class, name = "call"), + @JsonSubTypes.Type(value = OperationStepLookupSpec.class, name = "lookup") +}) +public abstract class OperationStepSpec { + + @JsonProperty("type") + private volatile String type; + + @JsonProperty("name") + private volatile String name; + + public OperationStepSpec() { + this(null, null); + } + + public OperationStepSpec(String type, String name) { + this.type = type; + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/src/test/java/io/naftiko/spec/exposes/OperationStepDeserializationTest.java b/src/test/java/io/naftiko/spec/exposes/OperationStepDeserializationTest.java new file mode 100644 index 0000000..a791b5a --- /dev/null +++ b/src/test/java/io/naftiko/spec/exposes/OperationStepDeserializationTest.java @@ -0,0 +1,122 @@ +/** + * 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.spec.exposes; + +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for OperationStep deserialization. + * Validates polymorphic deserialization of call and lookup steps from YAML. + */ +public class OperationStepDeserializationTest { + + @Test + public void testDeserializeCallStep() throws Exception { + String yaml = """ + type: call + name: fetch-database + call: notion.get-database + with: + database_id: "$this.sample.database_id" + """; + + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + OperationStepSpec step = yamlMapper.readValue(yaml, OperationStepSpec.class); + + assertNotNull(step, "Step should not be null"); + assertInstanceOf(OperationStepCallSpec.class, step, "Step should be OperationStepCallSpec"); + + OperationStepCallSpec callStep = (OperationStepCallSpec) step; + assertEquals("call", callStep.getType(), "Type should be 'call'"); + assertEquals("fetch-database", callStep.getName(), "Name mismatch"); + assertEquals("notion.get-database", callStep.getCall(), "Call reference mismatch"); + assertNotNull(callStep.getWith(), "With parameters should not be null"); + assertTrue(callStep.getWith().containsKey("database_id"), "With should contain database_id"); + } + + @Test + public void testDeserializeCallStepWithoutWith() throws Exception { + String yaml = """ + type: call + name: list-items + call: api.list-items + """; + + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + OperationStepSpec step = yamlMapper.readValue(yaml, OperationStepSpec.class); + + assertInstanceOf(OperationStepCallSpec.class, step); + OperationStepCallSpec callStep = (OperationStepCallSpec) step; + assertEquals("list-items", callStep.getName()); + assertEquals("api.list-items", callStep.getCall()); + assertNull(callStep.getWith(), "With should be null when not specified"); + } + + @Test + public void testDeserializeLookupStep() throws Exception { + String yaml = """ + type: lookup + name: find-user + index: list-users + match: email + lookupValue: "$this.sample.user_email" + outputParameters: + - login + - id + - fullName + """; + + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + OperationStepSpec step = yamlMapper.readValue(yaml, OperationStepSpec.class); + + assertNotNull(step, "Step should not be null"); + assertInstanceOf(OperationStepLookupSpec.class, step, "Step should be OperationStepLookupSpec"); + + OperationStepLookupSpec lookupStep = (OperationStepLookupSpec) step; + assertEquals("lookup", lookupStep.getType(), "Type should be 'lookup'"); + assertEquals("find-user", lookupStep.getName(), "Name mismatch"); + assertEquals("list-users", lookupStep.getIndex(), "Index mismatch"); + assertEquals("email", lookupStep.getMatch(), "Match field mismatch"); + assertEquals("$this.sample.user_email", lookupStep.getLookupValue(), "Lookup value mismatch"); + assertEquals(3, lookupStep.getOutputParameters().size(), "Should have 3 output parameters"); + assertTrue(lookupStep.getOutputParameters().contains("login"), "Should contain 'login'"); + assertTrue(lookupStep.getOutputParameters().contains("id"), "Should contain 'id'"); + assertTrue(lookupStep.getOutputParameters().contains("fullName"), "Should contain 'fullName'"); + } + + @Test + public void testDeserializeLookupStepSingleOutputParameter() throws Exception { + String yaml = """ + type: lookup + name: find-role + index: roles + match: id + lookupValue: "$this.sample.role_id" + outputParameters: + - role_name + """; + + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + OperationStepSpec step = yamlMapper.readValue(yaml, OperationStepSpec.class); + + assertInstanceOf(OperationStepLookupSpec.class, step); + OperationStepLookupSpec lookupStep = (OperationStepLookupSpec) step; + assertEquals(1, lookupStep.getOutputParameters().size()); + assertEquals("role_name", lookupStep.getOutputParameters().get(0)); + } + +} diff --git a/src/test/java/io/naftiko/spec/exposes/OperationStepRoundTripTest.java b/src/test/java/io/naftiko/spec/exposes/OperationStepRoundTripTest.java new file mode 100644 index 0000000..50622be --- /dev/null +++ b/src/test/java/io/naftiko/spec/exposes/OperationStepRoundTripTest.java @@ -0,0 +1,190 @@ +/** + * 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.spec.exposes; + +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import static org.junit.jupiter.api.Assertions.*; +import java.util.HashMap; +import java.util.Map; + +/** + * Test suite for OperationStep round-trip serialization/deserialization. + * Validates that data is not lost when reading from YAML and writing back to JSON. + */ +public class OperationStepRoundTripTest { + + @Test + public void testCallStepRoundTrip() throws Exception { + String yaml = """ + type: call + name: fetch-database + call: notion.get-database + with: + database_id: "$this.sample.database_id" + page_size: 100 + """; + + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + ObjectMapper jsonMapper = new ObjectMapper(); + + // Deserialize from YAML + OperationStepSpec original = yamlMapper.readValue(yaml, OperationStepSpec.class); + + // Serialize to JSON + String jsonSerialized = jsonMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(original); + + // Deserialize back from JSON + OperationStepSpec roundTrip = jsonMapper.readValue(jsonSerialized, OperationStepSpec.class); + + assertInstanceOf(OperationStepCallSpec.class, roundTrip); + OperationStepCallSpec callStep = (OperationStepCallSpec) roundTrip; + + assertEquals("call", callStep.getType(), "Type mismatch"); + assertEquals("fetch-database", callStep.getName(), "Name mismatch"); + assertEquals("notion.get-database", callStep.getCall(), "Call reference mismatch"); + assertNotNull(callStep.getWith(), "With should not be null"); + assertEquals(2, callStep.getWith().size(), "With should have 2 entries"); + assertEquals("$this.sample.database_id", callStep.getWith().get("database_id")); + assertEquals(100, callStep.getWith().get("page_size")); + } + + @Test + public void testLookupStepRoundTrip() throws Exception { + String yaml = """ + type: lookup + name: find-user + index: list-users + match: email + lookupValue: "$this.sample.user_email" + outputParameters: + - login + - id + - department + """; + + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + ObjectMapper jsonMapper = new ObjectMapper(); + + // Deserialize from YAML + OperationStepSpec original = yamlMapper.readValue(yaml, OperationStepSpec.class); + + // Serialize to JSON + String jsonSerialized = jsonMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(original); + + // Deserialize back from JSON + OperationStepSpec roundTrip = jsonMapper.readValue(jsonSerialized, OperationStepSpec.class); + + assertInstanceOf(OperationStepLookupSpec.class, roundTrip); + OperationStepLookupSpec lookupStep = (OperationStepLookupSpec) roundTrip; + + assertEquals("lookup", lookupStep.getType(), "Type mismatch"); + assertEquals("find-user", lookupStep.getName(), "Name mismatch"); + assertEquals("list-users", lookupStep.getIndex(), "Index mismatch"); + assertEquals("email", lookupStep.getMatch(), "Match field mismatch"); + assertEquals("$this.sample.user_email", lookupStep.getLookupValue(), "Lookup value mismatch"); + assertEquals(3, lookupStep.getOutputParameters().size(), "Should have 3 output parameters"); + assertTrue(lookupStep.getOutputParameters().contains("login")); + assertTrue(lookupStep.getOutputParameters().contains("id")); + assertTrue(lookupStep.getOutputParameters().contains("department")); + } + + @Test + public void testCallStepWithoutWithRoundTrip() throws Exception { + String yaml = """ + type: call + name: get-status + call: api.get-status + """; + + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + ObjectMapper jsonMapper = new ObjectMapper(); + + OperationStepSpec original = yamlMapper.readValue(yaml, OperationStepSpec.class); + String jsonSerialized = jsonMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(original); + OperationStepSpec roundTrip = jsonMapper.readValue(jsonSerialized, OperationStepSpec.class); + + assertInstanceOf(OperationStepCallSpec.class, roundTrip); + OperationStepCallSpec callStep = (OperationStepCallSpec) roundTrip; + + assertEquals("get-status", callStep.getName()); + assertEquals("api.get-status", callStep.getCall()); + assertNull(callStep.getWith()); + } + + @Test + public void testProgrammaticCallStepCreation() throws Exception { + Map withMap = new HashMap<>(); + withMap.put("user_id", "$this.sample.user_id"); + withMap.put("include_details", true); + + OperationStepCallSpec callStep = new OperationStepCallSpec("fetch-user", "api.get-user", withMap); + + ObjectMapper jsonMapper = new ObjectMapper(); + + // Serialize to JSON + String jsonSerialized = jsonMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(callStep); + + // Deserialize back + OperationStepSpec roundTrip = jsonMapper.readValue(jsonSerialized, OperationStepSpec.class); + + assertInstanceOf(OperationStepCallSpec.class, roundTrip); + OperationStepCallSpec restored = (OperationStepCallSpec) roundTrip; + + assertEquals("call", restored.getType()); + assertEquals("fetch-user", restored.getName()); + assertEquals("api.get-user", restored.getCall()); + assertEquals(2, restored.getWith().size()); + } + + @Test + public void testProgrammaticLookupStepCreation() throws Exception { + java.util.List outputParams = new java.util.ArrayList<>(); + outputParams.add("name"); + outputParams.add("email"); + + OperationStepLookupSpec lookupStep = new OperationStepLookupSpec( + "resolve-user", + "users", + "id", + "$.user_id", + outputParams + ); + + ObjectMapper jsonMapper = new ObjectMapper(); + + // Serialize to JSON + String jsonSerialized = jsonMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(lookupStep); + + // Deserialize back + OperationStepSpec roundTrip = jsonMapper.readValue(jsonSerialized, OperationStepSpec.class); + + assertInstanceOf(OperationStepLookupSpec.class, roundTrip); + OperationStepLookupSpec restored = (OperationStepLookupSpec) roundTrip; + + assertEquals("lookup", restored.getType()); + assertEquals("resolve-user", restored.getName()); + assertEquals("users", restored.getIndex()); + assertEquals("id", restored.getMatch()); + assertEquals("$.user_id", restored.getLookupValue()); + assertEquals(2, restored.getOutputParameters().size()); + } + +}