From 8b321458f87b55a6db033576f5e005af6c28ad25 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:17:30 +0200 Subject: [PATCH 1/2] fix(openapi): preserve array-form (JSON Schema / OAS 3.1) examples _extract_examples only handled 'examples' as an OpenAPI map of named Example Objects, while _schema_without_example_keys strips 'examples' from schemas before spreading. So a JSON Schema / OpenAPI 3.1 schema-level 'examples' list was extracted as nothing and then dropped - silent data loss. Handle the list form too: list entries are literal example values; the map form keeps its Example Object 'value' handling. Adds a test covering array-form schema examples on both request body and response. Bumps utcp-http 1.1.9 -> 1.1.10. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../http/pyproject.toml | 2 +- .../http/src/utcp_http/openapi_converter.py | 26 ++++++--- .../http/tests/test_openapi_converter.py | 54 +++++++++++++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index ef2cdda..e43174b 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.10" 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..26af4d6 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): diff --git a/plugins/communication_protocols/http/tests/test_openapi_converter.py b/plugins/communication_protocols/http/tests/test_openapi_converter.py index 9cf7709..d282b9d 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -316,3 +316,57 @@ 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"] From 3e00914b440fb68669554218bfae264dbf54e507 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:34:29 +0200 Subject: [PATCH 2/2] fix(openapi): make example de-duplication type-aware and order-insensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _merge_examples de-duped with `ex not in merged`, i.e. Python `==`. That collapses semantically distinct JSON examples: True == 1, False == 0, 1 == 1.0 — so mixing such values silently dropped one. Switch to a canonical JSON key (json.dumps with sorted keys) for identity: order-insensitive for objects and type-aware for scalars. Aligns the de-dup behavior with the TypeScript implementation. Bumps utcp-http 1.1.10 -> 1.1.11. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../http/pyproject.toml | 2 +- .../http/src/utcp_http/openapi_converter.py | 10 +++- .../http/tests/test_openapi_converter.py | 53 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index e43174b..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.10" +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 26af4d6..7eb3504 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -401,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 d282b9d..30fdebf 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -370,3 +370,56 @@ def test_openapi_converter_array_form_schema_examples(): 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]