Skip to content

Commit fd30247

Browse files
authored
Merge pull request #96 from universal-tool-calling-protocol/dev
fix(openapi): preserve array-form (JSON Schema / OAS 3.1) examples
2 parents b10d882 + 89a9832 commit fd30247

3 files changed

Lines changed: 135 additions & 10 deletions

File tree

plugins/communication_protocols/http/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "utcp-http"
7-
version = "1.1.9"
7+
version = "1.1.11"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]

plugins/communication_protocols/http/src/utcp_http/openapi_converter.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -360,20 +360,30 @@ def _resolve_ref_obj(self, obj: Any, visited: Optional[set] = None) -> Any:
360360

361361
def _extract_examples(self, obj: Dict[str, Any]) -> Optional[List[Any]]:
362362
"""
363-
Extract examples from an OpenAPI parameter or Media Type Object (Parameter, Media Type, Schema).
364-
365-
Supports both 'example' (single value) and 'examples' (map of Example Objects).
363+
Extract examples from an OpenAPI Parameter, Media Type, or Schema object.
364+
365+
Handles all three shapes the spec allows:
366+
- 'example' (single value) - OpenAPI Parameter / Media Type / 3.0 Schema.
367+
- 'examples' as a map of named Example Objects - OpenAPI Parameter /
368+
Media Type Object (each entry carries an inline 'value').
369+
- 'examples' as a list of literal values - JSON Schema / OpenAPI 3.1
370+
Schema Object.
371+
366372
Returns a list of example values suitable for JSON Schema 'examples' keyword.
367373
"""
368374
examples = []
369-
375+
370376
# Handle single 'example' field
371377
if "example" in obj and obj["example"] is not None:
372378
examples.append(obj["example"])
373-
374-
# Handle 'examples' map (OpenAPI 3.0+)
375-
if "examples" in obj and isinstance(obj["examples"], dict):
376-
for example_obj in obj["examples"].values():
379+
380+
examples_obj = obj.get("examples")
381+
if isinstance(examples_obj, list):
382+
# JSON Schema / OpenAPI 3.1 Schema form: a plain list of example values.
383+
examples.extend(examples_obj)
384+
elif isinstance(examples_obj, dict):
385+
# OpenAPI 3.0 form: a map of named Example Objects.
386+
for example_obj in examples_obj.values():
377387
if isinstance(example_obj, dict) and "$ref" in example_obj:
378388
example_obj = self._resolve_ref_obj(example_obj, set()) or {}
379389
if isinstance(example_obj, dict):
@@ -391,13 +401,21 @@ def _merge_examples(self, *objs: Optional[Dict[str, Any]]) -> Optional[List[Any]
391401
Used to combine examples that can appear at more than one level for the
392402
same value, e.g. a Media Type Object and the Schema Object beneath it.
393403
Returns a list suitable for the JSON Schema 'examples' keyword, or None.
404+
405+
De-duplication uses a canonical JSON serialization (sorted keys) as the
406+
identity. This is order-insensitive for objects and type-aware, so it
407+
does not collapse semantically distinct examples the way Python's ``==``
408+
would (``True == 1``, ``False == 0``, ``1 == 1.0``).
394409
"""
395410
merged: List[Any] = []
411+
seen: set = set()
396412
for obj in objs:
397413
if not isinstance(obj, dict):
398414
continue
399415
for ex in self._extract_examples(obj) or []:
400-
if ex not in merged:
416+
key = json.dumps(ex, sort_keys=True, default=str)
417+
if key not in seen:
418+
seen.add(key)
401419
merged.append(ex)
402420
return merged or None
403421

plugins/communication_protocols/http/tests/test_openapi_converter.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,110 @@ def test_openapi_converter_schema_level_examples_normalized():
316316
assert "example" not in body_param.model_dump(by_alias=True)
317317

318318
assert tool.outputs.examples == [{"id": "w_1"}]
319+
320+
321+
def test_openapi_converter_array_form_schema_examples():
322+
"""Array-form (JSON Schema / OpenAPI 3.1) schema 'examples' are preserved, not dropped."""
323+
openapi_spec = {
324+
"openapi": "3.1.0",
325+
"info": {"title": "Test API", "version": "1.0.0"},
326+
"paths": {
327+
"/gadgets": {
328+
"post": {
329+
"operationId": "createGadget",
330+
"requestBody": {
331+
"content": {
332+
"application/json": {
333+
"schema": {
334+
"type": "object",
335+
"properties": {"name": {"type": "string"}},
336+
# JSON Schema 'examples' keyword: a list of values
337+
"examples": [{"name": "Gadget A"}, {"name": "Gadget B"}],
338+
}
339+
}
340+
}
341+
},
342+
"responses": {
343+
"200": {
344+
"description": "ok",
345+
"content": {
346+
"application/json": {
347+
"schema": {
348+
"type": "string",
349+
"examples": ["ok", "done"],
350+
}
351+
}
352+
},
353+
}
354+
},
355+
}
356+
}
357+
},
358+
}
359+
360+
converter = OpenApiConverter(openapi_spec)
361+
manual = converter.convert()
362+
363+
tool = next((t for t in manual.tools if t.name == "createGadget"), None)
364+
assert tool is not None
365+
366+
body_param = tool.inputs.properties.get("body")
367+
assert body_param is not None
368+
assert body_param.examples == [{"name": "Gadget A"}, {"name": "Gadget B"}]
369+
# examples surface in the normalized field on serialization
370+
assert body_param.model_dump(by_alias=True).get("examples") == [{"name": "Gadget A"}, {"name": "Gadget B"}]
371+
372+
assert tool.outputs.examples == ["ok", "done"]
373+
374+
375+
def test_openapi_converter_example_dedup_is_type_aware_and_order_insensitive():
376+
"""De-dup keeps distinct JSON types (true vs 1) and collapses key-reordered objects."""
377+
openapi_spec = {
378+
"openapi": "3.0.0",
379+
"info": {"title": "Test API", "version": "1.0.0"},
380+
"paths": {
381+
"/items": {
382+
"post": {
383+
"operationId": "createItem",
384+
"requestBody": {
385+
"content": {
386+
"application/json": {
387+
# media-type example with one key order...
388+
"examples": {"e1": {"value": {"a": 1, "b": 2}}},
389+
"schema": {
390+
"type": "object",
391+
# ...schema example with the other key order -> collapses to one
392+
"examples": [{"b": 2, "a": 1}],
393+
},
394+
}
395+
}
396+
},
397+
"responses": {
398+
"200": {
399+
"description": "ok",
400+
"content": {
401+
"application/json": {
402+
"schema": {
403+
"type": "object",
404+
# true and 1 are distinct; duplicate true removed
405+
"examples": [True, 1, True],
406+
}
407+
}
408+
},
409+
}
410+
},
411+
}
412+
}
413+
},
414+
}
415+
416+
converter = OpenApiConverter(openapi_spec)
417+
manual = converter.convert()
418+
419+
tool = next((t for t in manual.tools if t.name == "createItem"), None)
420+
assert tool is not None
421+
422+
# {a:1,b:2} and {b:2,a:1} are the same example -> collapsed to one
423+
assert tool.inputs.properties.get("body").examples == [{"a": 1, "b": 2}]
424+
# True vs 1 kept distinct (== would have collapsed them); duplicate True removed
425+
assert tool.outputs.examples == [True, 1]

0 commit comments

Comments
 (0)