Skip to content

Commit aa0c6f8

Browse files
h3xxitclaude
andcommitted
fix(openapi): validate HTTP method and normalize schema-level examples
Issue 2 — replace the blind cast on http_method with an explicit guard. OpenAPI allows operations (options/head/trace) that HttpCallTemplate's Literal type rejects; these are now skipped with a warning instead of crashing conversion via a Pydantic ValidationError. A shared SUPPORTED_HTTP_METHODS constant backs both the operation-loop filter and the per-operation check, so the cast is now truthful rather than assumed. Issue 3 — make example handling consistent across params, request bodies, and responses. Examples that appear at the schema level (not just the media type / parameter object) are now collected via _merge_examples and surfaced in the normalized JSON Schema 'examples' keyword, and the raw OpenAPI 'example'/'examples' keys are stripped before the schema is spread onto the property so they no longer leak through as untyped extra fields. This lines up with the explicit examples field added to JsonSchema in core (#91). Adds tests for unsupported-method skipping and schema-level example normalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3c940d9 commit aa0c6f8

2 files changed

Lines changed: 161 additions & 24 deletions

File tree

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

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
@@ -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"),

plugins/communication_protocols/http/tests/test_openapi_converter.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,94 @@ def test_openapi_converter_parameter_examples():
218218
example_value = body_param.examples[0]
219219
assert example_value["name"] == "Jane Smith"
220220
assert example_value["email"] == "jane@example.com"
221+
222+
223+
def test_openapi_converter_skips_unsupported_methods():
224+
"""Operations with HTTP methods HttpCallTemplate cannot represent are skipped, not crashed on."""
225+
openapi_spec = {
226+
"openapi": "3.0.0",
227+
"info": {"title": "Test API", "version": "1.0.0"},
228+
"paths": {
229+
"/things": {
230+
"get": {
231+
"operationId": "listThings",
232+
"responses": {"200": {"description": "ok"}},
233+
},
234+
# OPTIONS/HEAD/TRACE are valid OpenAPI but not in the call template Literal
235+
"options": {
236+
"operationId": "optionsThings",
237+
"responses": {"200": {"description": "ok"}},
238+
},
239+
"head": {
240+
"operationId": "headThings",
241+
"responses": {"200": {"description": "ok"}},
242+
},
243+
"trace": {
244+
"operationId": "traceThings",
245+
"responses": {"200": {"description": "ok"}},
246+
},
247+
}
248+
},
249+
}
250+
251+
converter = OpenApiConverter(openapi_spec)
252+
manual = converter.convert()
253+
254+
tool_names = {tool.name for tool in manual.tools}
255+
assert tool_names == {"listThings"}
256+
257+
258+
def test_openapi_converter_schema_level_examples_normalized():
259+
"""Examples declared at the schema level (not the media type) are normalized into 'examples'."""
260+
openapi_spec = {
261+
"openapi": "3.0.0",
262+
"info": {"title": "Test API", "version": "1.0.0"},
263+
"paths": {
264+
"/widgets": {
265+
"post": {
266+
"operationId": "createWidget",
267+
"requestBody": {
268+
"content": {
269+
"application/json": {
270+
"schema": {
271+
"type": "object",
272+
"properties": {"name": {"type": "string"}},
273+
# schema-level example, not a media-type 'examples' map
274+
"example": {"name": "Widget A"},
275+
}
276+
}
277+
}
278+
},
279+
"responses": {
280+
"200": {
281+
"description": "ok",
282+
"content": {
283+
"application/json": {
284+
"schema": {
285+
"type": "object",
286+
"properties": {"id": {"type": "string"}},
287+
"example": {"id": "w_1"},
288+
}
289+
}
290+
},
291+
}
292+
},
293+
}
294+
}
295+
},
296+
}
297+
298+
converter = OpenApiConverter(openapi_spec)
299+
manual = converter.convert()
300+
301+
tool = next((t for t in manual.tools if t.name == "createWidget"), None)
302+
assert tool is not None
303+
304+
body_param = tool.inputs.properties.get("body")
305+
assert body_param is not None
306+
# schema-level example surfaces in the normalized 'examples' keyword
307+
assert body_param.examples == [{"name": "Widget A"}]
308+
# raw 'example' key must not leak through as an extra field
309+
assert "example" not in body_param.model_dump(by_alias=True)
310+
311+
assert tool.outputs.examples == [{"id": "w_1"}]

0 commit comments

Comments
 (0)