diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index ef2cdda..d6c7221 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.9" +version = "1.1.11" authors = [ { name = "UTCP Contributors" }, ] 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 dc930f0..7eb3504 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -360,20 +360,30 @@ def _resolve_ref_obj(self, obj: Any, visited: Optional[set] = None) -> Any: 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). + Extract examples from an OpenAPI Parameter, Media Type, or Schema object. + + Handles all three shapes the spec allows: + - 'example' (single value) - OpenAPI Parameter / Media Type / 3.0 Schema. + - 'examples' as a map of named Example Objects - OpenAPI Parameter / + Media Type Object (each entry carries an inline 'value'). + - 'examples' as a list of literal values - JSON Schema / OpenAPI 3.1 + Schema Object. + 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(): + + examples_obj = obj.get("examples") + if isinstance(examples_obj, list): + # JSON Schema / OpenAPI 3.1 Schema form: a plain list of example values. + examples.extend(examples_obj) + elif isinstance(examples_obj, dict): + # OpenAPI 3.0 form: a map of named Example Objects. + for example_obj in examples_obj.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): @@ -391,13 +401,21 @@ def _merge_examples(self, *objs: Optional[Dict[str, Any]]) -> Optional[List[Any] 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. + + De-duplication uses a canonical JSON serialization (sorted keys) as the + identity. This is order-insensitive for objects and type-aware, so it + does not collapse semantically distinct examples the way Python's ``==`` + would (``True == 1``, ``False == 0``, ``1 == 1.0``). """ merged: List[Any] = [] + seen: set = set() for obj in objs: if not isinstance(obj, dict): continue for ex in self._extract_examples(obj) or []: - if ex not in merged: + key = json.dumps(ex, sort_keys=True, default=str) + if key not in seen: + seen.add(key) merged.append(ex) return merged or None diff --git a/plugins/communication_protocols/http/tests/test_openapi_converter.py b/plugins/communication_protocols/http/tests/test_openapi_converter.py index 9cf7709..30fdebf 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -316,3 +316,110 @@ def test_openapi_converter_schema_level_examples_normalized(): assert "example" not in body_param.model_dump(by_alias=True) assert tool.outputs.examples == [{"id": "w_1"}] + + +def test_openapi_converter_array_form_schema_examples(): + """Array-form (JSON Schema / OpenAPI 3.1) schema 'examples' are preserved, not dropped.""" + openapi_spec = { + "openapi": "3.1.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/gadgets": { + "post": { + "operationId": "createGadget", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + # JSON Schema 'examples' keyword: a list of values + "examples": [{"name": "Gadget A"}, {"name": "Gadget B"}], + } + } + } + }, + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "string", + "examples": ["ok", "done"], + } + } + }, + } + }, + } + } + }, + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + tool = next((t for t in manual.tools if t.name == "createGadget"), None) + assert tool is not None + + body_param = tool.inputs.properties.get("body") + assert body_param is not None + assert body_param.examples == [{"name": "Gadget A"}, {"name": "Gadget B"}] + # examples surface in the normalized field on serialization + assert body_param.model_dump(by_alias=True).get("examples") == [{"name": "Gadget A"}, {"name": "Gadget B"}] + + assert tool.outputs.examples == ["ok", "done"] + + +def test_openapi_converter_example_dedup_is_type_aware_and_order_insensitive(): + """De-dup keeps distinct JSON types (true vs 1) and collapses key-reordered objects.""" + openapi_spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "post": { + "operationId": "createItem", + "requestBody": { + "content": { + "application/json": { + # media-type example with one key order... + "examples": {"e1": {"value": {"a": 1, "b": 2}}}, + "schema": { + "type": "object", + # ...schema example with the other key order -> collapses to one + "examples": [{"b": 2, "a": 1}], + }, + } + } + }, + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "object", + # true and 1 are distinct; duplicate true removed + "examples": [True, 1, True], + } + } + }, + } + }, + } + } + }, + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + tool = next((t for t in manual.tools if t.name == "createItem"), None) + assert tool is not None + + # {a:1,b:2} and {b:2,a:1} are the same example -> collapsed to one + assert tool.inputs.properties.get("body").examples == [{"a": 1, "b": 2}] + # True vs 1 kept distinct (== would have collapsed them); duplicate True removed + assert tool.outputs.examples == [True, 1]