diff --git a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py index d89b07e..0054b29 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -18,7 +18,7 @@ """ import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Literal, cast import sys import uuid from urllib.parse import urljoin, urlparse @@ -29,6 +29,11 @@ from utcp_http.http_call_template import HttpCallTemplate from utcp_http._security import ensure_secure_url, is_loopback_url +# HTTP methods that HttpCallTemplate.http_method accepts. Kept as the single +# source of truth for both the operation loop filter and per-operation +# validation so the two can never drift apart. +SUPPORTED_HTTP_METHODS: Tuple[str, ...] = ("GET", "POST", "PUT", "DELETE", "PATCH") + class OpenApiConverter: """REQUIRED Converts OpenAPI specifications into UTCP tool definitions. @@ -185,7 +190,7 @@ def convert(self) -> UtcpManual: for path, path_item in self.spec.get("paths", {}).items(): for method, operation in path_item.items(): - if method.lower() in ['get', 'post', 'put', 'delete', 'patch']: + if method.upper() in SUPPORTED_HTTP_METHODS: tool = self._create_tool(path, method, operation, base_url) if tool: tools.append(tool) @@ -294,14 +299,14 @@ def _is_auth_compatible(self, openapi_auth: Optional[Auth], auth_tools: Optional # For API Key auth, check header name and location compatibility if hasattr(openapi_auth, 'var_name') and hasattr(auth_tools, 'var_name'): - openapi_var = openapi_auth.var_name.lower() if openapi_auth.var_name else "" - tools_var = auth_tools.var_name.lower() if auth_tools.var_name else "" + openapi_var = getattr(openapi_auth, 'var_name', "").lower() if getattr(openapi_auth, 'var_name', None) else "" + tools_var = getattr(auth_tools, 'var_name', "").lower() if getattr(auth_tools, 'var_name', None) else "" if openapi_var != tools_var: return False if hasattr(openapi_auth, 'location') and hasattr(auth_tools, 'location'): - if openapi_auth.location != auth_tools.location: + if getattr(openapi_auth, 'location', None) != getattr(auth_tools, 'location', None): return False return True @@ -346,6 +351,60 @@ def _resolve_ref_obj(self, obj: Any, visited: Optional[set] = None) -> Any: if isinstance(obj, dict) and "$ref" in obj: return self._resolve_ref_path(obj["$ref"], visited) return obj + + def _extract_examples(self, obj: Dict[str, Any]) -> Optional[List[Any]]: + """ + Extract examples from an OpenAPI parameter or Media Type Object (Parameter, Media Type, Schema). + + Supports both 'example' (single value) and 'examples' (map of Example Objects). + Returns a list of example values suitable for JSON Schema 'examples' keyword. + """ + examples = [] + + # Handle single 'example' field + if "example" in obj and obj["example"] is not None: + examples.append(obj["example"]) + + # Handle 'examples' map (OpenAPI 3.0+) + if "examples" in obj and isinstance(obj["examples"], dict): + for example_obj in obj["examples"].values(): + if isinstance(example_obj, dict) and "$ref" in example_obj: + example_obj = self._resolve_ref_obj(example_obj, set()) or {} + if isinstance(example_obj, dict): + # Example Object can have 'value' or 'externalValue' + if "value" in example_obj: + examples.append(example_obj["value"]) + # Note: externalValue is a URI reference, we skip it as it's not inline + + return examples if examples else None + + def _merge_examples(self, *objs: Optional[Dict[str, Any]]) -> Optional[List[Any]]: + """ + Collect and de-duplicate examples from several OpenAPI objects, preserving order. + + Used to combine examples that can appear at more than one level for the + same value, e.g. a Media Type Object and the Schema Object beneath it. + Returns a list suitable for the JSON Schema 'examples' keyword, or None. + """ + merged: List[Any] = [] + for obj in objs: + if not isinstance(obj, dict): + continue + for ex in self._extract_examples(obj) or []: + if ex not in merged: + merged.append(ex) + return merged or None + + @staticmethod + def _schema_without_example_keys(schema: Dict[str, Any]) -> Dict[str, Any]: + """ + Return a copy of a schema dict with the raw 'example'/'examples' keys removed. + + Examples are normalized into the JSON Schema 'examples' keyword via + _merge_examples, so the raw OpenAPI keys must not be spread back onto the + property or they would leak through as untyped extra fields. + """ + return {k: v for k, v in schema.items() if k not in ("example", "examples")} def _create_auth_from_scheme(self, scheme: Dict[str, Any], scheme_name: str) -> Optional[Auth]: """Creates an Auth object from an OpenAPI security scheme.""" @@ -462,6 +521,20 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u if not operation_id: return None + # Validate the HTTP method against what HttpCallTemplate accepts before + # building the tool. OpenAPI allows operations like 'options'/'head'/ + # 'trace' that the call template's Literal type rejects; skip them with a + # warning instead of letting Pydantic raise mid-conversion. This explicit + # check is also what makes the cast below truthful rather than a blind + # assertion. + http_method = method.upper() + if http_method not in SUPPORTED_HTTP_METHODS: + print( + f"Skipping operation '{operation_id}': unsupported HTTP method '{method}'.", + file=sys.stderr, + ) + return None + description = operation.get("summary") or operation.get("description", "") tags = operation.get("tags", []) @@ -474,7 +547,7 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u call_template = HttpCallTemplate( name=self.call_template_name, - http_method=method.upper(), + http_method=cast(Literal["GET", "POST", "PUT", "DELETE", "PATCH"], http_method), url=full_url, body_field=body_field if body_field else None, header_fields=header_fields if header_fields else None, @@ -523,10 +596,19 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch if param.get("in") == "body": body_field = "body" json_schema = self._resolve_ref_obj(param.get("schema", {}), set()) or {} - properties[body_field] = { + + # Examples can live on the parameter itself and on its schema; + # collect both into the normalized 'examples' keyword. + body_examples = self._merge_examples(param, json_schema) + + prop = { "description": param.get("description", "Request body"), - **json_schema, + **self._schema_without_example_keys(json_schema), } + if body_examples: + prop["examples"] = body_examples + + properties[body_field] = prop if param.get("required"): required.append(body_field) continue @@ -541,10 +623,19 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch schema["items"] = param.get("items") if "enum" in param: schema["enum"] = param.get("enum") - properties[param_name] = { + + # Examples can live on the parameter itself and on its schema; + # collect both into the normalized 'examples' keyword. + param_examples = self._merge_examples(param, schema) + + prop = { "description": param.get("description", ""), - **schema, + **self._schema_without_example_keys(schema), } + if param_examples: + prop["examples"] = param_examples + + properties[param_name] = prop if param.get("required"): required.append(param_name) @@ -554,13 +645,23 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch content = request_body.get("content", {}) json_schema = content.get("application/json", {}).get("schema") json_schema = self._resolve_ref_obj(json_schema, set()) if json_schema else None + + # Examples can live on the media type object and on the schema; + # collect both into the normalized 'examples' keyword. + media_type_obj = content.get("application/json", {}) + if json_schema: + body_examples = self._merge_examples(media_type_obj, json_schema) # Add a single 'body' field to represent the request body body_field = "body" - properties[body_field] = { + prop = { "description": json_schema.get("description", "Request body"), - **json_schema + **self._schema_without_example_keys(json_schema) } + if body_examples: + prop["examples"] = body_examples + + properties[body_field] = prop if json_schema.get("required"): required.append(body_field) @@ -575,14 +676,17 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema: return JsonSchema() json_schema = None + media_type_obj = None if "content" in success_response: content = success_response.get("content", {}) json_schema = content.get("application/json", {}).get("schema") + media_type_obj = content.get("application/json", {}) # Fallback to any content type if application/json missing if json_schema is None and isinstance(content, dict): for v in content.values(): if isinstance(v, dict) and "schema" in v: json_schema = v.get("schema") + media_type_obj = v break elif "schema" in success_response: # OpenAPI 2.0 json_schema = success_response.get("schema") @@ -593,6 +697,9 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema: # Resolve $ref in response schema json_schema = self._resolve_ref_obj(json_schema, set()) or {} + # Extract examples from response media type and schema level + response_examples = self._merge_examples(media_type_obj, json_schema) + schema_args = { "type": json_schema.get("type", "object"), "properties": json_schema.get("properties", {}), @@ -609,5 +716,9 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema: for attr in ["enum", "minimum", "maximum", "format"]: if attr in json_schema: schema_args[attr] = json_schema.get(attr) + + # Add examples if present + if response_examples: + schema_args["examples"] = response_examples return JsonSchema(**schema_args) diff --git a/plugins/communication_protocols/http/tests/test_openapi_converter.py b/plugins/communication_protocols/http/tests/test_openapi_converter.py index aa3f3cb..1cb3ac6 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -56,3 +56,256 @@ async def test_openapi_converter_with_auth_tools(): # Verify auth_tools is stored assert converter.auth_tools == auth_tools + + +def test_openapi_converter_parameter_examples(): + """Test that parameter examples are correctly extracted from OpenAPI spec.""" + # Create a minimal OpenAPI spec with parameter examples + openapi_spec = { + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/users/{userId}": { + "get": { + "operationId": "getUser", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": True, + "schema": { + "type": "string" + }, + "example": "user123" + }, + { + "name": "includeDetails", + "in": "query", + "description": "Include detailed information", + "required": False, + "schema": { + "type": "boolean" + }, + "examples": { + "trueExample": { + "summary": "Include details", + "value": True + }, + "falseExample": { + "summary": "Exclude details", + "value": False + } + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"} + } + }, + "examples": { + "userExample": { + "summary": "Example user", + "value": { + "id": "user123", + "name": "John Doe" + } + } + } + } + } + } + } + } + }, + "/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "description": "User to create", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + }, + "required": ["name", "email"] + }, + "examples": { + "newUser": { + "summary": "New user example", + "value": { + "name": "Jane Smith", + "email": "jane@example.com" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "User created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + } + } + } + } + } + } + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + assert len(manual.tools) == 2 + + # Test getUser tool - path and query parameter examples + get_user_tool = next((tool for tool in manual.tools if tool.name == "getUser"), None) + assert get_user_tool is not None + + # Check path parameter example + user_id_param = get_user_tool.inputs.properties.get("userId") + assert user_id_param is not None + assert user_id_param.examples is not None + assert "user123" in user_id_param.examples + + # Check query parameter examples + include_details_param = get_user_tool.inputs.properties.get("includeDetails") + assert include_details_param is not None + assert include_details_param.examples is not None + assert True in include_details_param.examples + assert False in include_details_param.examples + + # Check response examples + assert get_user_tool.outputs.examples is not None + assert len(get_user_tool.outputs.examples) > 0 + example_value = get_user_tool.outputs.examples[0] + assert example_value["id"] == "user123" + assert example_value["name"] == "John Doe" + + # Test createUser tool - request body examples + create_user_tool = next((tool for tool in manual.tools if tool.name == "createUser"), None) + assert create_user_tool is not None + + body_param = create_user_tool.inputs.properties.get("body") + assert body_param is not None + assert body_param.examples is not None + assert len(body_param.examples) > 0 + example_value = body_param.examples[0] + assert example_value["name"] == "Jane Smith" + assert example_value["email"] == "jane@example.com" + + +def test_openapi_converter_skips_unsupported_methods(): + """Operations with HTTP methods HttpCallTemplate cannot represent are skipped, not crashed on.""" + openapi_spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/things": { + "get": { + "operationId": "listThings", + "responses": {"200": {"description": "ok"}}, + }, + # OPTIONS/HEAD/TRACE are valid OpenAPI but not in the call template Literal + "options": { + "operationId": "optionsThings", + "responses": {"200": {"description": "ok"}}, + }, + "head": { + "operationId": "headThings", + "responses": {"200": {"description": "ok"}}, + }, + "trace": { + "operationId": "traceThings", + "responses": {"200": {"description": "ok"}}, + }, + } + }, + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + tool_names = {tool.name for tool in manual.tools} + assert tool_names == {"listThings"} + + +def test_openapi_converter_schema_level_examples_normalized(): + """Examples declared at the schema level (not the media type) are normalized into 'examples'.""" + openapi_spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/widgets": { + "post": { + "operationId": "createWidget", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + # schema-level example, not a media-type 'examples' map + "example": {"name": "Widget A"}, + } + } + } + }, + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "example": {"id": "w_1"}, + } + } + }, + } + }, + } + } + }, + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + tool = next((t for t in manual.tools if t.name == "createWidget"), None) + assert tool is not None + + body_param = tool.inputs.properties.get("body") + assert body_param is not None + # schema-level example surfaces in the normalized 'examples' keyword + assert body_param.examples == [{"name": "Widget A"}] + # raw 'example' key must not leak through as an extra field + assert "example" not in body_param.model_dump(by_alias=True) + + assert tool.outputs.examples == [{"id": "w_1"}]