Skip to content

Commit 5525e8a

Browse files
authored
Merge pull request #88 from shane-rand/fix/openapi-converter-misses-examples-on-params
fix: OpenAPI Converter was not parsing examples for request parameters
2 parents 0698a61 + aa0c6f8 commit 5525e8a

2 files changed

Lines changed: 376 additions & 12 deletions

File tree

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

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"""
1919

2020
import json
21-
from typing import Any, Dict, List, Optional, Tuple
21+
from typing import Any, Dict, List, Optional, Tuple, Literal, cast
2222
import sys
2323
import uuid
2424
from urllib.parse import urljoin, urlparse
@@ -29,6 +29,11 @@
2929
from utcp_http.http_call_template import HttpCallTemplate
3030
from utcp_http._security import ensure_secure_url, is_loopback_url
3131

32+
# HTTP methods that HttpCallTemplate.http_method accepts. Kept as the single
33+
# source of truth for both the operation loop filter and per-operation
34+
# validation so the two can never drift apart.
35+
SUPPORTED_HTTP_METHODS: Tuple[str, ...] = ("GET", "POST", "PUT", "DELETE", "PATCH")
36+
3237
class OpenApiConverter:
3338
"""REQUIRED
3439
Converts OpenAPI specifications into UTCP tool definitions.
@@ -185,7 +190,7 @@ def convert(self) -> UtcpManual:
185190

186191
for path, path_item in self.spec.get("paths", {}).items():
187192
for method, operation in path_item.items():
188-
if method.lower() in ['get', 'post', 'put', 'delete', 'patch']:
193+
if method.upper() in SUPPORTED_HTTP_METHODS:
189194
tool = self._create_tool(path, method, operation, base_url)
190195
if tool:
191196
tools.append(tool)
@@ -294,14 +299,14 @@ def _is_auth_compatible(self, openapi_auth: Optional[Auth], auth_tools: Optional
294299

295300
# For API Key auth, check header name and location compatibility
296301
if hasattr(openapi_auth, 'var_name') and hasattr(auth_tools, 'var_name'):
297-
openapi_var = openapi_auth.var_name.lower() if openapi_auth.var_name else ""
298-
tools_var = auth_tools.var_name.lower() if auth_tools.var_name else ""
302+
openapi_var = getattr(openapi_auth, 'var_name', "").lower() if getattr(openapi_auth, 'var_name', None) else ""
303+
tools_var = getattr(auth_tools, 'var_name', "").lower() if getattr(auth_tools, 'var_name', None) else ""
299304

300305
if openapi_var != tools_var:
301306
return False
302307

303308
if hasattr(openapi_auth, 'location') and hasattr(auth_tools, 'location'):
304-
if openapi_auth.location != auth_tools.location:
309+
if getattr(openapi_auth, 'location', None) != getattr(auth_tools, 'location', None):
305310
return False
306311

307312
return True
@@ -346,6 +351,60 @@ def _resolve_ref_obj(self, obj: Any, visited: Optional[set] = None) -> Any:
346351
if isinstance(obj, dict) and "$ref" in obj:
347352
return self._resolve_ref_path(obj["$ref"], visited)
348353
return obj
354+
355+
def _extract_examples(self, obj: Dict[str, Any]) -> Optional[List[Any]]:
356+
"""
357+
Extract examples from an OpenAPI parameter or Media Type Object (Parameter, Media Type, Schema).
358+
359+
Supports both 'example' (single value) and 'examples' (map of Example Objects).
360+
Returns a list of example values suitable for JSON Schema 'examples' keyword.
361+
"""
362+
examples = []
363+
364+
# Handle single 'example' field
365+
if "example" in obj and obj["example"] is not None:
366+
examples.append(obj["example"])
367+
368+
# Handle 'examples' map (OpenAPI 3.0+)
369+
if "examples" in obj and isinstance(obj["examples"], dict):
370+
for example_obj in obj["examples"].values():
371+
if isinstance(example_obj, dict) and "$ref" in example_obj:
372+
example_obj = self._resolve_ref_obj(example_obj, set()) or {}
373+
if isinstance(example_obj, dict):
374+
# Example Object can have 'value' or 'externalValue'
375+
if "value" in example_obj:
376+
examples.append(example_obj["value"])
377+
# Note: externalValue is a URI reference, we skip it as it's not inline
378+
379+
return examples if examples else None
380+
381+
def _merge_examples(self, *objs: Optional[Dict[str, Any]]) -> Optional[List[Any]]:
382+
"""
383+
Collect and de-duplicate examples from several OpenAPI objects, preserving order.
384+
385+
Used to combine examples that can appear at more than one level for the
386+
same value, e.g. a Media Type Object and the Schema Object beneath it.
387+
Returns a list suitable for the JSON Schema 'examples' keyword, or None.
388+
"""
389+
merged: List[Any] = []
390+
for obj in objs:
391+
if not isinstance(obj, dict):
392+
continue
393+
for ex in self._extract_examples(obj) or []:
394+
if ex not in merged:
395+
merged.append(ex)
396+
return merged or None
397+
398+
@staticmethod
399+
def _schema_without_example_keys(schema: Dict[str, Any]) -> Dict[str, Any]:
400+
"""
401+
Return a copy of a schema dict with the raw 'example'/'examples' keys removed.
402+
403+
Examples are normalized into the JSON Schema 'examples' keyword via
404+
_merge_examples, so the raw OpenAPI keys must not be spread back onto the
405+
property or they would leak through as untyped extra fields.
406+
"""
407+
return {k: v for k, v in schema.items() if k not in ("example", "examples")}
349408

350409
def _create_auth_from_scheme(self, scheme: Dict[str, Any], scheme_name: str) -> Optional[Auth]:
351410
"""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
462521
if not operation_id:
463522
return None
464523

524+
# Validate the HTTP method against what HttpCallTemplate accepts before
525+
# building the tool. OpenAPI allows operations like 'options'/'head'/
526+
# 'trace' that the call template's Literal type rejects; skip them with a
527+
# warning instead of letting Pydantic raise mid-conversion. This explicit
528+
# check is also what makes the cast below truthful rather than a blind
529+
# assertion.
530+
http_method = method.upper()
531+
if http_method not in SUPPORTED_HTTP_METHODS:
532+
print(
533+
f"Skipping operation '{operation_id}': unsupported HTTP method '{method}'.",
534+
file=sys.stderr,
535+
)
536+
return None
537+
465538
description = operation.get("summary") or operation.get("description", "")
466539
tags = operation.get("tags", [])
467540

@@ -474,7 +547,7 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u
474547

475548
call_template = HttpCallTemplate(
476549
name=self.call_template_name,
477-
http_method=method.upper(),
550+
http_method=cast(Literal["GET", "POST", "PUT", "DELETE", "PATCH"], http_method),
478551
url=full_url,
479552
body_field=body_field if body_field else None,
480553
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
523596
if param.get("in") == "body":
524597
body_field = "body"
525598
json_schema = self._resolve_ref_obj(param.get("schema", {}), set()) or {}
526-
properties[body_field] = {
599+
600+
# Examples can live on the parameter itself and on its schema;
601+
# collect both into the normalized 'examples' keyword.
602+
body_examples = self._merge_examples(param, json_schema)
603+
604+
prop = {
527605
"description": param.get("description", "Request body"),
528-
**json_schema,
606+
**self._schema_without_example_keys(json_schema),
529607
}
608+
if body_examples:
609+
prop["examples"] = body_examples
610+
611+
properties[body_field] = prop
530612
if param.get("required"):
531613
required.append(body_field)
532614
continue
@@ -541,10 +623,19 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
541623
schema["items"] = param.get("items")
542624
if "enum" in param:
543625
schema["enum"] = param.get("enum")
544-
properties[param_name] = {
626+
627+
# Examples can live on the parameter itself and on its schema;
628+
# collect both into the normalized 'examples' keyword.
629+
param_examples = self._merge_examples(param, schema)
630+
631+
prop = {
545632
"description": param.get("description", ""),
546-
**schema,
633+
**self._schema_without_example_keys(schema),
547634
}
635+
if param_examples:
636+
prop["examples"] = param_examples
637+
638+
properties[param_name] = prop
548639
if param.get("required"):
549640
required.append(param_name)
550641

@@ -554,13 +645,23 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
554645
content = request_body.get("content", {})
555646
json_schema = content.get("application/json", {}).get("schema")
556647
json_schema = self._resolve_ref_obj(json_schema, set()) if json_schema else None
648+
649+
# Examples can live on the media type object and on the schema;
650+
# collect both into the normalized 'examples' keyword.
651+
media_type_obj = content.get("application/json", {})
652+
557653
if json_schema:
654+
body_examples = self._merge_examples(media_type_obj, json_schema)
558655
# Add a single 'body' field to represent the request body
559656
body_field = "body"
560-
properties[body_field] = {
657+
prop = {
561658
"description": json_schema.get("description", "Request body"),
562-
**json_schema
659+
**self._schema_without_example_keys(json_schema)
563660
}
661+
if body_examples:
662+
prop["examples"] = body_examples
663+
664+
properties[body_field] = prop
564665
if json_schema.get("required"):
565666
required.append(body_field)
566667

@@ -575,14 +676,17 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema:
575676
return JsonSchema()
576677

577678
json_schema = None
679+
media_type_obj = None
578680
if "content" in success_response:
579681
content = success_response.get("content", {})
580682
json_schema = content.get("application/json", {}).get("schema")
683+
media_type_obj = content.get("application/json", {})
581684
# Fallback to any content type if application/json missing
582685
if json_schema is None and isinstance(content, dict):
583686
for v in content.values():
584687
if isinstance(v, dict) and "schema" in v:
585688
json_schema = v.get("schema")
689+
media_type_obj = v
586690
break
587691
elif "schema" in success_response: # OpenAPI 2.0
588692
json_schema = success_response.get("schema")
@@ -593,6 +697,9 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema:
593697
# Resolve $ref in response schema
594698
json_schema = self._resolve_ref_obj(json_schema, set()) or {}
595699

700+
# Extract examples from response media type and schema level
701+
response_examples = self._merge_examples(media_type_obj, json_schema)
702+
596703
schema_args = {
597704
"type": json_schema.get("type", "object"),
598705
"properties": json_schema.get("properties", {}),
@@ -609,5 +716,9 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema:
609716
for attr in ["enum", "minimum", "maximum", "format"]:
610717
if attr in json_schema:
611718
schema_args[attr] = json_schema.get(attr)
719+
720+
# Add examples if present
721+
if response_examples:
722+
schema_args["examples"] = response_examples
612723

613724
return JsonSchema(**schema_args)

0 commit comments

Comments
 (0)