1818"""
1919
2020import json
21- from typing import Any , Dict , List , Optional , Tuple
21+ from typing import Any , Dict , List , Optional , Tuple , Literal , cast
2222import sys
2323import uuid
2424from urllib .parse import urljoin , urlparse
2929from utcp_http .http_call_template import HttpCallTemplate
3030from 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+
3237class 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