From ad8014d8f1072d8230e34577318d28e6cedb628f Mon Sep 17 00:00:00 2001 From: Shane Smithrand Date: Tue, 16 Jun 2026 21:17:43 -0400 Subject: [PATCH 1/5] fix: OpenAPI Converter was not parsing examples for request parameters --- .../http/src/utcp_http/openapi_converter.py | 75 +++++++- .../http/tests/test_openapi_converter.py | 162 ++++++++++++++++++ 2 files changed, 229 insertions(+), 8 deletions(-) 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 d53bdfd..e9ae112 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 urlparse @@ -248,14 +248,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 @@ -300,6 +300,30 @@ 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): + # 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 _create_auth_from_scheme(self, scheme: Dict[str, Any], scheme_name: str) -> Optional[Auth]: """Creates an Auth object from an OpenAPI security scheme.""" @@ -417,7 +441,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"], method.upper()), url=full_url, body_field=body_field if body_field else None, header_fields=header_fields if header_fields else None, @@ -466,10 +490,18 @@ 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] = { + + # Extract examples from body parameter + body_examples = self._extract_examples(param) + + prop = { "description": param.get("description", "Request body"), **json_schema, } + if body_examples: + prop["examples"] = body_examples + + properties[body_field] = prop if param.get("required"): required.append(body_field) continue @@ -484,10 +516,18 @@ 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] = { + + # Extract examples from parameter + param_examples = self._extract_examples(param) + + prop = { "description": param.get("description", ""), **schema, } + if param_examples: + prop["examples"] = param_examples + + properties[param_name] = prop if param.get("required"): required.append(param_name) @@ -497,13 +537,22 @@ 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 + + # Extract examples from request body media type + media_type_obj = content.get("application/json", {}) + body_examples = self._extract_examples(media_type_obj) + if 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 } + if body_examples: + prop["examples"] = body_examples + + properties[body_field] = prop if json_schema.get("required"): required.append(body_field) @@ -518,14 +567,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") @@ -536,6 +588,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 + response_examples = self._extract_examples(media_type_obj) if media_type_obj else None + schema_args = { "type": json_schema.get("type", "object"), "properties": json_schema.get("properties", {}), @@ -552,5 +607,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..b71fd0c 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -56,3 +56,165 @@ 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" From 15878ea09a50dfc68d216e0595171293113de09f Mon Sep 17 00:00:00 2001 From: Shane Smithrand Date: Tue, 16 Jun 2026 21:39:29 -0400 Subject: [PATCH 2/5] fix: resolve in extract_examples. extract_outputs now extracts examples on json_schema --- .../http/src/utcp_http/openapi_converter.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 e9ae112..3db1898 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -317,6 +317,8 @@ def _extract_examples(self, obj: Dict[str, Any]) -> Optional[List[Any]]: # 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: @@ -588,8 +590,12 @@ 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 - response_examples = self._extract_examples(media_type_obj) if media_type_obj else None + # Extract examples from response media type and schema level + response_examples = list(self._extract_examples(media_type_obj) or []) if media_type_obj else [] + for ex in self._extract_examples(json_schema) or []: + if ex not in response_examples: + response_examples.append(ex) + response_examples = response_examples or None schema_args = { "type": json_schema.get("type", "object"), From e31be05f8278b038cdf698b368d6da9f7f3c0c2e Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:47:50 +0200 Subject: [PATCH 3/5] feat(core): add explicit examples field to JsonSchema JsonSchema previously accepted `examples` only via `extra="allow"`, leaving it untyped, undocumented, and invisible to type checkers. Declare it as Optional[List[JsonType]] so the JSON Schema `examples` keyword is a first-class, validated field. Supports PR #88 (OpenAPI converter examples parsing), which currently relies on the extra-field fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/src/utcp/data/tool.py | 2 ++ core/tests/data/__init__.py | 0 core/tests/data/test_tool_schema.py | 38 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 core/tests/data/__init__.py create mode 100644 core/tests/data/test_tool_schema.py diff --git a/core/src/utcp/data/tool.py b/core/src/utcp/data/tool.py index effdd5c..73ab5b4 100644 --- a/core/src/utcp/data/tool.py +++ b/core/src/utcp/data/tool.py @@ -38,6 +38,7 @@ class JsonSchema(BaseModel): default: Optional schema default value. format: Optional schema format. additionalProperties: Optional schema additional properties. + examples: Optional list of example values for the schema. """ schema_: Optional[str] = Field(None, alias="$schema") id_: Optional[str] = Field(None, alias="$id") @@ -50,6 +51,7 @@ class JsonSchema(BaseModel): enum: Optional[List[JsonType]] = None const: Optional[JsonType] = None default: Optional[JsonType] = None + examples: Optional[List[JsonType]] = None format: Optional[str] = None additionalProperties: Optional[Union[bool, "JsonSchema"]] = None pattern: Optional[str] = None diff --git a/core/tests/data/__init__.py b/core/tests/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/data/test_tool_schema.py b/core/tests/data/test_tool_schema.py new file mode 100644 index 0000000..ab9e5fe --- /dev/null +++ b/core/tests/data/test_tool_schema.py @@ -0,0 +1,38 @@ +"""Tests for the JsonSchema model, including the `examples` field.""" + +from utcp.data.tool import JsonSchema, JsonSchemaSerializer + + +def test_jsonschema_examples_field_is_typed(): + """`examples` is a declared field, not just an extra attribute.""" + assert "examples" in JsonSchema.model_fields + + schema = JsonSchema(type="string", examples=["user123", "user456"]) + assert schema.examples == ["user123", "user456"] + + +def test_jsonschema_examples_default_none(): + """`examples` defaults to None when absent.""" + schema = JsonSchema(type="string") + assert schema.examples is None + + +def test_jsonschema_examples_roundtrip(): + """`examples` survives serialize -> validate roundtrip.""" + serializer = JsonSchemaSerializer() + schema = JsonSchema( + type="object", + examples=[{"id": "user123", "name": "John Doe"}], + ) + + as_dict = serializer.to_dict(schema) + assert as_dict["examples"] == [{"id": "user123", "name": "John Doe"}] + + restored = serializer.validate_dict(as_dict) + assert restored.examples == schema.examples + + +def test_jsonschema_examples_allows_mixed_json_types(): + """`examples` accepts any JSON value (string, bool, number, object).""" + schema = JsonSchema(examples=["a", True, 1, 1.5, None, {"k": "v"}, [1, 2]]) + assert schema.examples == ["a", True, 1, 1.5, None, {"k": "v"}, [1, 2]] From aa0c6f8916e3331f465623880e103e953de08774 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:00:46 +0200 Subject: [PATCH 4/5] fix(openapi): validate HTTP method and normalize schema-level examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 2 — replace the blind cast on http_method with an explicit guard. OpenAPI allows operations (options/head/trace) that HttpCallTemplate's Literal type rejects; these are now skipped with a warning instead of crashing conversion via a Pydantic ValidationError. A shared SUPPORTED_HTTP_METHODS constant backs both the operation-loop filter and the per-operation check, so the cast is now truthful rather than assumed. Issue 3 — make example handling consistent across params, request bodies, and responses. Examples that appear at the schema level (not just the media type / parameter object) are now collected via _merge_examples and surfaced in the normalized JSON Schema 'examples' keyword, and the raw OpenAPI 'example'/'examples' keys are stripped before the schema is spread onto the property so they no longer leak through as untyped extra fields. This lines up with the explicit examples field added to JsonSchema in core (#91). Adds tests for unsupported-method skipping and schema-level example normalization. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../http/src/utcp_http/openapi_converter.py | 94 ++++++++++++++----- .../http/tests/test_openapi_converter.py | 91 ++++++++++++++++++ 2 files changed, 161 insertions(+), 24 deletions(-) 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 bc0d175..0054b29 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -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) @@ -370,8 +375,36 @@ def _extract_examples(self, obj: Dict[str, Any]) -> Optional[List[Any]]: 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.""" @@ -488,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", []) @@ -500,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=cast(Literal["GET", "POST", "PUT", "DELETE", "PATCH"], 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, @@ -549,17 +596,18 @@ 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 {} - - # Extract examples from body parameter - body_examples = self._extract_examples(param) - + + # 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) @@ -576,16 +624,17 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch if "enum" in param: schema["enum"] = param.get("enum") - # Extract examples from parameter - param_examples = self._extract_examples(param) - + # 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) @@ -597,20 +646,21 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch json_schema = content.get("application/json", {}).get("schema") json_schema = self._resolve_ref_obj(json_schema, set()) if json_schema else None - # Extract examples from request body media type + # 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", {}) - body_examples = self._extract_examples(media_type_obj) - + 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" 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) @@ -648,11 +698,7 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema: json_schema = self._resolve_ref_obj(json_schema, set()) or {} # Extract examples from response media type and schema level - response_examples = list(self._extract_examples(media_type_obj) or []) if media_type_obj else [] - for ex in self._extract_examples(json_schema) or []: - if ex not in response_examples: - response_examples.append(ex) - response_examples = response_examples or None + response_examples = self._merge_examples(media_type_obj, json_schema) schema_args = { "type": json_schema.get("type", "object"), diff --git a/plugins/communication_protocols/http/tests/test_openapi_converter.py b/plugins/communication_protocols/http/tests/test_openapi_converter.py index b71fd0c..1cb3ac6 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -218,3 +218,94 @@ def test_openapi_converter_parameter_examples(): 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"}] From 51683c55278b01711e9f190fde3b325b418ffd00 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:24:12 +0200 Subject: [PATCH 5/5] chore(release): utcp 1.1.3, utcp-http 1.1.8 Publishes the OpenAPI examples parsing work: the explicit JsonSchema `examples` field (core) and the converter changes that normalize examples and validate HTTP methods (http). Co-Authored-By: Claude Opus 4.8 (1M context) --- core/pyproject.toml | 2 +- plugins/communication_protocols/http/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index c5c8f07..eb81f77 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp" -version = "1.1.2" +version = "1.1.3" authors = [ { name = "UTCP Contributors" }, ] diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index 9d754a2..10bb447 100644 --- a/plugins/communication_protocols/http/pyproject.toml +++ b/plugins/communication_protocols/http/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-http" -version = "1.1.7" +version = "1.1.8" authors = [ { name = "UTCP Contributors" }, ]