From b96c178a9ec3103d32714d005af8ce2b31c5d835 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:40:26 -0500 Subject: [PATCH] feat: Add enhanced descriptions and metadata support Add description fields to API specification elements for improved documentation generation and agent discoverability. Implements metadata extraction utilities for comprehensive capability documentation. - Add description field to ApiServerStepSpec and ApiServerCallSpec - Create DocumentationMetadata utility for extracting and formatting specs in tests - Support description serialization/deserialization in YAML/JSON roundtrips - Add 16 comprehensive tests covering metadata extraction and formatting - All 110 tests passing with zero regressions SCOPE: - Spec layer: 2 new description fields - Tests: 2 test classes (16 tests total),1 new DocumentationMetadata utility with 4 core methods --- .../spec/exposes/ApiServerCallSpec.java | 20 +- .../spec/exposes/ApiServerStepSpec.java | 20 +- .../engine/DocumentationMetadataTest.java | 191 ++++++++++++++++++ .../DescriptionMetadataRoundTripTest.java | 139 +++++++++++++ .../naftiko/spec/DocumentationMetadata.java | 175 ++++++++++++++++ 5 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/naftiko/engine/DocumentationMetadataTest.java create mode 100644 src/test/java/io/naftiko/spec/DescriptionMetadataRoundTripTest.java create mode 100644 src/test/java/io/naftiko/spec/DocumentationMetadata.java diff --git a/src/main/java/io/naftiko/spec/exposes/ApiServerCallSpec.java b/src/main/java/io/naftiko/spec/exposes/ApiServerCallSpec.java index b6e9bfd..e7568ac 100644 --- a/src/main/java/io/naftiko/spec/exposes/ApiServerCallSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/ApiServerCallSpec.java @@ -32,18 +32,26 @@ public class ApiServerCallSpec { @JsonInclude(JsonInclude.Include.NON_NULL) private volatile Map with; + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String description; + public ApiServerCallSpec() { - this(null, null); + this(null, null, null); } @JsonCreator(mode = JsonCreator.Mode.DELEGATING) public ApiServerCallSpec(String operation) { - this(operation, null); + this(operation, null, null); } public ApiServerCallSpec(String operation, Map with) { + this(operation, with, null); + } + + public ApiServerCallSpec(String operation, Map with, String description) { this.operation = operation; this.with = with != null ? new ConcurrentHashMap<>(with) : new ConcurrentHashMap<>(); + this.description = description; } public String getOperation() { @@ -62,6 +70,14 @@ public void setWith(Map with) { this.with = with != null ? new ConcurrentHashMap<>(with) : new ConcurrentHashMap<>(); } + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + /** * Gets a parameter value from the "with" map by key. * diff --git a/src/main/java/io/naftiko/spec/exposes/ApiServerStepSpec.java b/src/main/java/io/naftiko/spec/exposes/ApiServerStepSpec.java index 40813e0..afaca84 100644 --- a/src/main/java/io/naftiko/spec/exposes/ApiServerStepSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/ApiServerStepSpec.java @@ -32,17 +32,25 @@ public class ApiServerStepSpec { @JsonInclude(JsonInclude.Include.NON_NULL) private volatile Map with; + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String description; + public ApiServerStepSpec() { - this(null, null); + this(null, null, null); } public ApiServerStepSpec(ApiServerCallSpec call) { - this(call, null); + this(call, null, null); } public ApiServerStepSpec(ApiServerCallSpec call, Map with) { + this(call, with, null); + } + + public ApiServerStepSpec(ApiServerCallSpec call, Map with, String description) { this.call = call; this.with = with != null ? new ConcurrentHashMap<>(with) : null; + this.description = description; } public ApiServerCallSpec getCall() { @@ -61,5 +69,13 @@ public void setWith(Map with) { this.with = with != null ? new ConcurrentHashMap<>(with) : null; } + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } diff --git a/src/test/java/io/naftiko/engine/DocumentationMetadataTest.java b/src/test/java/io/naftiko/engine/DocumentationMetadataTest.java new file mode 100644 index 0000000..d3f43fa --- /dev/null +++ b/src/test/java/io/naftiko/engine/DocumentationMetadataTest.java @@ -0,0 +1,191 @@ +/** + * 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import io.naftiko.spec.DocumentationMetadata; +import io.naftiko.spec.InputParameterSpec; +import io.naftiko.spec.OutputParameterSpec; +import io.naftiko.spec.exposes.ApiServerOperationSpec; +import io.naftiko.spec.exposes.ApiServerResourceSpec; +import io.naftiko.spec.exposes.ApiServerStepSpec; +import io.naftiko.spec.exposes.ApiServerCallSpec; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for DocumentationMetadata engine utility. + * Validates extraction and formatting of specification documentation. + */ +public class DocumentationMetadataTest { + + private ApiServerResourceSpec resource; + private ApiServerOperationSpec operation; + + @BeforeEach + public void setUp() { + resource = new ApiServerResourceSpec("/users", null, null, "User management endpoints", null); + + operation = new ApiServerOperationSpec(resource, "GET", "getUser", "Get User"); + operation.setDescription("Retrieves a user by ID"); + } + + @Test + public void testExtractResourceDocumentation() { + Map docs = DocumentationMetadata.extractResourceDocumentation(resource); + + assertNotNull(docs); + assertEquals("/users", docs.get("path")); + assertEquals("User management endpoints", docs.get("description")); + assertNotNull(docs.get("operations")); + } + + @Test + public void testExtractResourceDocumentationWithMultipleOperations() { + ApiServerOperationSpec op1 = new ApiServerOperationSpec(resource, "GET", "list", "List Users"); + op1.setDescription("List all users"); + resource.getOperations().add(op1); + + ApiServerOperationSpec op2 = new ApiServerOperationSpec(resource, "POST", "create", "Create User"); + op2.setDescription("Create a new user"); + resource.getOperations().add(op2); + + Map docs = DocumentationMetadata.extractResourceDocumentation(resource); + + @SuppressWarnings("unchecked") + Map operations = (Map) docs.get("operations"); + assertEquals(2, operations.size()); + assertEquals("List all users", operations.get("list")); + assertEquals("Create a new user", operations.get("create")); + } + + @Test + public void testExtractParameterDocumentation() { + List inputs = new ArrayList<>(); + InputParameterSpec inputParam = new InputParameterSpec("userId", "string", "query", null); + inputParam.setDescription("The user ID"); + inputs.add(inputParam); + + List outputs = new ArrayList<>(); + OutputParameterSpec outputParam = new OutputParameterSpec("user", "object", "body", null); + outputParam.setDescription("The user object"); + outputs.add(outputParam); + + Map docs = DocumentationMetadata.extractParameterDocumentation(inputs, outputs); + + assertNotNull(docs); + @SuppressWarnings("unchecked") + Map inputDocs = (Map) docs.get("inputs"); + @SuppressWarnings("unchecked") + Map outputDocs = (Map) docs.get("outputs"); + + assertEquals(1, inputDocs.size()); + assertTrue(inputDocs.get("userId").contains("The user ID")); + assertTrue(inputDocs.get("userId").contains("string")); + + assertEquals(1, outputDocs.size()); + assertTrue(outputDocs.get("user").contains("The user object")); + assertTrue(outputDocs.get("user").contains("object")); + } + + @Test + public void testExtractStepDocumentation() { + List steps = new ArrayList<>(); + + ApiServerCallSpec call1 = new ApiServerCallSpec("users.get", null, "Fetch user details"); + ApiServerStepSpec step1 = new ApiServerStepSpec(call1, null, "Retrieve user information"); + steps.add(step1); + + ApiServerCallSpec call2 = new ApiServerCallSpec("users.audit", null); + ApiServerStepSpec step2 = new ApiServerStepSpec(call2, null, "Log the access"); + steps.add(step2); + + List> stepDocs = DocumentationMetadata.extractStepDocumentation(steps); + + assertEquals(2, stepDocs.size()); + + // Step 1 + assertEquals(0, stepDocs.get(0).get("index")); + assertEquals("Retrieve user information", stepDocs.get(0).get("description")); + assertEquals("users.get", stepDocs.get(0).get("operation")); + assertEquals("Fetch user details", stepDocs.get(0).get("callDescription")); + + // Step 2 + assertEquals(1, stepDocs.get(1).get("index")); + assertEquals("Log the access", stepDocs.get(1).get("description")); + assertEquals("users.audit", stepDocs.get(1).get("operation")); + } + + @Test + public void testFormatOperationDocumentation() { + ApiServerCallSpec call = new ApiServerCallSpec("db.fetchUser", null); + ApiServerStepSpec step = new ApiServerStepSpec(call, null, "Fetch user from database"); + operation.getSteps().add(step); + + String doc = DocumentationMetadata.formatOperationDocumentation(resource, operation); + + assertNotNull(doc); + assertTrue(doc.contains("Resource: /users")); + assertTrue(doc.contains("User management endpoints")); + assertTrue(doc.contains("Operation: getUser")); + assertTrue(doc.contains("Retrieves a user by ID")); + assertTrue(doc.contains("Steps:")); + assertTrue(doc.contains("Fetch user from database")); + } + + @Test + public void testApiServerStepSpecWithDescription() { + ApiServerCallSpec call = new ApiServerCallSpec("getUser"); + ApiServerStepSpec step = new ApiServerStepSpec(call, null, "Fetch user by ID"); + + assertEquals("Fetch user by ID", step.getDescription()); + } + + @Test + public void testApiServerCallSpecWithDescription() { + ApiServerCallSpec call = new ApiServerCallSpec("users.get", new HashMap<>(), "Retrieve a user"); + + assertEquals("Retrieve a user", call.getDescription()); + } + + @Test + public void testExtractParameterDocumentationWithNullLists() { + Map docs = DocumentationMetadata.extractParameterDocumentation(null, null); + + assertNotNull(docs); + @SuppressWarnings("unchecked") + Map inputs = (Map) docs.get("inputs"); + @SuppressWarnings("unchecked") + Map outputs = (Map) docs.get("outputs"); + + assertEquals(0, inputs.size()); + assertEquals(0, outputs.size()); + } + + @Test + public void testFormatOperationDocumentationWithoutSteps() { + String doc = DocumentationMetadata.formatOperationDocumentation(resource, operation); + + assertNotNull(doc); + assertTrue(doc.contains("Resource: /users")); + assertTrue(doc.contains("Operation: getUser")); + } + +} diff --git a/src/test/java/io/naftiko/spec/DescriptionMetadataRoundTripTest.java b/src/test/java/io/naftiko/spec/DescriptionMetadataRoundTripTest.java new file mode 100644 index 0000000..3788510 --- /dev/null +++ b/src/test/java/io/naftiko/spec/DescriptionMetadataRoundTripTest.java @@ -0,0 +1,139 @@ +/** + * 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; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import io.naftiko.spec.exposes.ApiServerStepSpec; +import io.naftiko.spec.exposes.ApiServerCallSpec; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for enhanced description metadata serialization. + * Validates that descriptions are properly preserved in YAML/JSON roundtrips. + */ +public class DescriptionMetadataRoundTripTest { + + private ObjectMapper yamlMapper; + private ObjectMapper jsonMapper; + + @BeforeEach + public void setUp() { + this.yamlMapper = new ObjectMapper(new YAMLFactory()); + this.jsonMapper = new ObjectMapper(); + } + + @Test + public void testApiServerStepSpecDescriptionYamlRoundTrip() throws Exception { + ApiServerCallSpec call = new ApiServerCallSpec("users.fetch", null, "Retrieves user data"); + ApiServerStepSpec step = new ApiServerStepSpec(call, null, "Fetch user information"); + + // Serialize to YAML string + String yaml = yamlMapper.writeValueAsString(step); + + // Deserialize back + ApiServerStepSpec restored = yamlMapper.readValue(yaml, ApiServerStepSpec.class); + + assertEquals("Fetch user information", restored.getDescription()); + assertNotNull(restored.getCall()); + assertEquals("users.fetch", restored.getCall().getOperation()); + assertEquals("Retrieves user data", restored.getCall().getDescription()); + } + + @Test + public void testApiServerCallSpecDescriptionYamlRoundTrip() throws Exception { + ApiServerCallSpec call = new ApiServerCallSpec("users.delete", null, "Remove a user permanently"); + + // Serialize to YAML string + String yaml = yamlMapper.writeValueAsString(call); + + // Deserialize back + ApiServerCallSpec restored = yamlMapper.readValue(yaml, ApiServerCallSpec.class); + + assertEquals("users.delete", restored.getOperation()); + assertEquals("Remove a user permanently", restored.getDescription()); + } + + @Test + public void testApiServerStepSpecDescriptionJsonRoundTrip() throws Exception { + ApiServerCallSpec call = new ApiServerCallSpec("logs.write", null); + ApiServerStepSpec step = new ApiServerStepSpec(call, null, "Write audit log"); + + // Serialize to JSON string + String json = jsonMapper.writeValueAsString(step); + + // Deserialize back + ApiServerStepSpec restored = jsonMapper.readValue(json, ApiServerStepSpec.class); + + assertEquals("Write audit log", restored.getDescription()); + assertEquals("logs.write", restored.getCall().getOperation()); + } + + @Test + public void testApiServerCallSpecWithoutDescriptionYamlRoundTrip() throws Exception { + ApiServerCallSpec call = new ApiServerCallSpec("users.get"); + + // Serialize to YAML string + String yaml = yamlMapper.writeValueAsString(call); + + // Deserialize back + ApiServerCallSpec restored = yamlMapper.readValue(yaml, ApiServerCallSpec.class); + + assertEquals("users.get", restored.getOperation()); + assertNull(restored.getDescription()); + } + + @Test + public void testDescriptionExcludedWhenNullInSerialization() throws Exception { + ApiServerStepSpec step = new ApiServerStepSpec(new ApiServerCallSpec("test"), null, null); + + // Serialize to YAML + String yaml = yamlMapper.writeValueAsString(step); + + // Verify description field is not included (JsonInclude.NON_NULL) + assertFalse(yaml.contains("description:"), + "Null description should not be serialized due to JsonInclude.NON_NULL"); + } + + @Test + public void testDescriptionIncludedWhenPresentInSerialization() throws Exception { + ApiServerStepSpec step = new ApiServerStepSpec(new ApiServerCallSpec("test"), null, "Test operation"); + + // Serialize to YAML + String yaml = yamlMapper.writeValueAsString(step); + + // Verify description field is included + assertTrue(yaml.contains("description:"), + "Non-null description should be serialized"); + assertTrue(yaml.contains("Test operation")); + } + + @Test + public void testMultipleStepsWithDescriptionsYamlRoundTrip() throws Exception { + String yamlContent = "call:\n operation: users.get\n description: Fetch user\n" + + "description: Get user step\n"; + + ApiServerStepSpec step = yamlMapper.readValue(yamlContent, ApiServerStepSpec.class); + + assertEquals("Get user step", step.getDescription()); + assertEquals("users.get", step.getCall().getOperation()); + assertEquals("Fetch user", step.getCall().getDescription()); + } + +} diff --git a/src/test/java/io/naftiko/spec/DocumentationMetadata.java b/src/test/java/io/naftiko/spec/DocumentationMetadata.java new file mode 100644 index 0000000..85fe6f7 --- /dev/null +++ b/src/test/java/io/naftiko/spec/DocumentationMetadata.java @@ -0,0 +1,175 @@ +/** + * 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; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import io.naftiko.spec.InputParameterSpec; +import io.naftiko.spec.OutputParameterSpec; +import io.naftiko.spec.exposes.ApiServerOperationSpec; +import io.naftiko.spec.exposes.ApiServerResourceSpec; +import io.naftiko.spec.exposes.ApiServerStepSpec; + +/** + * Manages documentation metadata for capability specifications. + * Extracts and provides access to descriptions from various spec elements. + */ +public class DocumentationMetadata { + + /** + * Extracts documentation for a resource including its description and operations. + * + * @param resource The resource specification + * @return Map containing resource documentation + */ + public static Map extractResourceDocumentation(ApiServerResourceSpec resource) { + Map docs = new HashMap<>(); + + if (resource != null) { + docs.put("path", resource.getPath()); + docs.put("description", resource.getDescription()); + + // Extract operation descriptions + Map operations = new HashMap<>(); + List ops = resource.getOperations(); + if (ops != null) { + for (ApiServerOperationSpec op : ops) { + if (op != null && op.getName() != null) { + operations.put(op.getName(), op.getDescription() != null ? op.getDescription() : ""); + } + } + } + docs.put("operations", operations); + } + + return docs; + } + + /** + * Extracts parameter documentation for an operation. + * + * @param inputParams List of input parameters + * @param outputParams List of output parameters + * @return Map containing parameter documentation + */ + public static Map extractParameterDocumentation( + List inputParams, + List outputParams) { + Map docs = new HashMap<>(); + + // Document input parameters + Map inputs = new HashMap<>(); + if (inputParams != null) { + for (InputParameterSpec param : inputParams) { + if (param != null && param.getName() != null) { + String desc = param.getDescription() != null ? param.getDescription() : ""; + String type = param.getType() != null ? param.getType() : "unknown"; + inputs.put(param.getName(), String.format("%s (%s)", desc, type)); + } + } + } + docs.put("inputs", inputs); + + // Document output parameters + Map outputs = new HashMap<>(); + if (outputParams != null) { + for (OutputParameterSpec param : outputParams) { + if (param != null && param.getName() != null) { + String desc = param.getDescription() != null ? param.getDescription() : ""; + String type = param.getType() != null ? param.getType() : "unknown"; + outputs.put(param.getName(), String.format("%s (%s)", desc, type)); + } + } + } + docs.put("outputs", outputs); + + return docs; + } + + /** + * Extracts step documentation including descriptions of each step. + * + * @param steps List of operation steps + * @return List of step documentation with descriptions + */ + public static List> extractStepDocumentation(List steps) { + List> stepDocs = new java.util.ArrayList<>(); + + if (steps != null) { + for (int i = 0; i < steps.size(); i++) { + ApiServerStepSpec step = steps.get(i); + if (step != null) { + Map stepDoc = new HashMap<>(); + stepDoc.put("index", i); + stepDoc.put("description", step.getDescription() != null ? step.getDescription() : ""); + + if (step.getCall() != null) { + stepDoc.put("operation", step.getCall().getOperation()); + if (step.getCall().getDescription() != null) { + stepDoc.put("callDescription", step.getCall().getDescription()); + } + } + + stepDocs.add(stepDoc); + } + } + } + + return stepDocs; + } + + /** + * Creates a human-readable summary of operation documentation. + * + * @param resource The resource specification + * @param operation The operation specification + * @return Formatted documentation string + */ + public static String formatOperationDocumentation(ApiServerResourceSpec resource, + ApiServerOperationSpec operation) { + StringBuilder doc = new StringBuilder(); + + if (resource != null && operation != null) { + doc.append("Resource: ").append(resource.getPath()).append("\n"); + if (resource.getDescription() != null && !resource.getDescription().isEmpty()) { + doc.append("Description: ").append(resource.getDescription()).append("\n"); + } + + doc.append("\nOperation: ").append(operation.getName()).append("\n"); + if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { + doc.append("Description: ").append(operation.getDescription()).append("\n"); + } + + if (operation.getSteps() != null && !operation.getSteps().isEmpty()) { + doc.append("\nSteps:\n"); + for (int i = 0; i < operation.getSteps().size(); i++) { + ApiServerStepSpec step = operation.getSteps().get(i); + doc.append(" ").append(i + 1).append(". "); + if (step.getDescription() != null && !step.getDescription().isEmpty()) { + doc.append(step.getDescription()); + } else if (step.getCall() != null) { + doc.append("Call: ").append(step.getCall().getOperation()); + } else { + doc.append("(No description)"); + } + doc.append("\n"); + } + } + } + + return doc.toString(); + } + +}