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 )
@@ -370,8 +375,36 @@ def _extract_examples(self, obj: Dict[str, Any]) -> Optional[List[Any]]:
370375 if "value" in example_obj :
371376 examples .append (example_obj ["value" ])
372377 # Note: externalValue is a URI reference, we skip it as it's not inline
373-
378+
374379 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" )}
375408
376409 def _create_auth_from_scheme (self , scheme : Dict [str , Any ], scheme_name : str ) -> Optional [Auth ]:
377410 """Creates an Auth object from an OpenAPI security scheme."""
@@ -488,6 +521,20 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u
488521 if not operation_id :
489522 return None
490523
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+
491538 description = operation .get ("summary" ) or operation .get ("description" , "" )
492539 tags = operation .get ("tags" , [])
493540
@@ -500,7 +547,7 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u
500547
501548 call_template = HttpCallTemplate (
502549 name = self .call_template_name ,
503- http_method = cast (Literal ["GET" , "POST" , "PUT" , "DELETE" , "PATCH" ], method . upper () ),
550+ http_method = cast (Literal ["GET" , "POST" , "PUT" , "DELETE" , "PATCH" ], http_method ),
504551 url = full_url ,
505552 body_field = body_field if body_field else None ,
506553 header_fields = header_fields if header_fields else None ,
@@ -549,17 +596,18 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
549596 if param .get ("in" ) == "body" :
550597 body_field = "body"
551598 json_schema = self ._resolve_ref_obj (param .get ("schema" , {}), set ()) or {}
552-
553- # Extract examples from body parameter
554- body_examples = self ._extract_examples (param )
555-
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+
556604 prop = {
557605 "description" : param .get ("description" , "Request body" ),
558- ** json_schema ,
606+ ** self . _schema_without_example_keys ( json_schema ) ,
559607 }
560608 if body_examples :
561609 prop ["examples" ] = body_examples
562-
610+
563611 properties [body_field ] = prop
564612 if param .get ("required" ):
565613 required .append (body_field )
@@ -576,16 +624,17 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
576624 if "enum" in param :
577625 schema ["enum" ] = param .get ("enum" )
578626
579- # Extract examples from parameter
580- param_examples = self ._extract_examples (param )
581-
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+
582631 prop = {
583632 "description" : param .get ("description" , "" ),
584- ** schema ,
633+ ** self . _schema_without_example_keys ( schema ) ,
585634 }
586635 if param_examples :
587636 prop ["examples" ] = param_examples
588-
637+
589638 properties [param_name ] = prop
590639 if param .get ("required" ):
591640 required .append (param_name )
@@ -597,20 +646,21 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
597646 json_schema = content .get ("application/json" , {}).get ("schema" )
598647 json_schema = self ._resolve_ref_obj (json_schema , set ()) if json_schema else None
599648
600- # Extract examples from request body media type
649+ # Examples can live on the media type object and on the schema;
650+ # collect both into the normalized 'examples' keyword.
601651 media_type_obj = content .get ("application/json" , {})
602- body_examples = self ._extract_examples (media_type_obj )
603-
652+
604653 if json_schema :
654+ body_examples = self ._merge_examples (media_type_obj , json_schema )
605655 # Add a single 'body' field to represent the request body
606656 body_field = "body"
607657 prop = {
608658 "description" : json_schema .get ("description" , "Request body" ),
609- ** json_schema
659+ ** self . _schema_without_example_keys ( json_schema )
610660 }
611661 if body_examples :
612662 prop ["examples" ] = body_examples
613-
663+
614664 properties [body_field ] = prop
615665 if json_schema .get ("required" ):
616666 required .append (body_field )
@@ -648,11 +698,7 @@ def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema:
648698 json_schema = self ._resolve_ref_obj (json_schema , set ()) or {}
649699
650700 # Extract examples from response media type and schema level
651- response_examples = list (self ._extract_examples (media_type_obj ) or []) if media_type_obj else []
652- for ex in self ._extract_examples (json_schema ) or []:
653- if ex not in response_examples :
654- response_examples .append (ex )
655- response_examples = response_examples or None
701+ response_examples = self ._merge_examples (media_type_obj , json_schema )
656702
657703 schema_args = {
658704 "type" : json_schema .get ("type" , "object" ),
0 commit comments