diff --git a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py index 7eb3504..dd41771 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -562,7 +562,7 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u description = operation.get("summary") or operation.get("description", "") tags = operation.get("tags", []) - inputs, header_fields, body_field = self._extract_inputs(path, operation) + inputs, header_fields, body_field, body_content_type = self._extract_inputs(path, operation) outputs = self._extract_outputs(operation) auth = self._extract_auth(operation) @@ -575,7 +575,8 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u url=full_url, body_field=body_field if body_field else None, header_fields=header_fields if header_fields else None, - auth=auth + auth=auth, + content_type=body_content_type or "application/json" ) return Tool( @@ -587,8 +588,8 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u tool_call_template=call_template ) - def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSchema, List[str], Optional[str]]: - """Extracts input schema, header fields, and body field from an OpenAPI operation. + def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSchema, List[str], Optional[str], Optional[str]]: + """Extracts input schema, header fields, body field, and body media type from an OpenAPI operation. - Merges path-level and operation-level parameters - Resolves $ref for parameters @@ -598,6 +599,7 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch required = [] header_fields = [] body_field = None + body_content_type = None # Merge path-level and operation-level parameters path_item = self.spec.get("paths", {}).get(path, {}) if path else {} @@ -639,6 +641,16 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch # Non-body parameter schema = self._resolve_ref_obj(param.get("schema", {}), set()) or {} + param_content_obj = None + if not schema and isinstance(param.get("content"), dict): + # OpenAPI 3.x allows a Parameter Object to carry its schema under a + # 'content' map (media-type -> Media Type Object) instead of 'schema', + # e.g. for parameters that need a media type other than the implicit one. + for media_type_obj_candidate in param["content"].values(): + if isinstance(media_type_obj_candidate, dict): + param_content_obj = media_type_obj_candidate + schema = self._resolve_ref_obj(param_content_obj.get("schema", {}), set()) or {} + break if not schema: # OpenAPI 2.0 non-body params use top-level type/items if "type" in param: @@ -647,10 +659,10 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch schema["items"] = param.get("items") if "enum" in param: schema["enum"] = param.get("enum") - - # Examples can live on the parameter itself and on its schema; - # collect both into the normalized 'examples' keyword. - param_examples = self._merge_examples(param, schema) + + # Examples can live on the parameter itself, its schema, and (for the + # 'content' form) the Media Type Object; collect all into 'examples'. + param_examples = self._merge_examples(param, schema, param_content_obj) prop = { "description": param.get("description", ""), @@ -667,12 +679,20 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch request_body = operation.get("requestBody") if request_body: content = request_body.get("content", {}) - json_schema = content.get("application/json", {}).get("schema") - json_schema = self._resolve_ref_obj(json_schema, set()) if json_schema else None - - # Examples can live on the media type object and on the schema; - # collect both into the normalized 'examples' keyword. + body_content_type = "application/json" if "application/json" in content else None media_type_obj = content.get("application/json", {}) + json_schema = media_type_obj.get("schema") + # Fall back to the first schema-bearing media type when the body has no + # application/json entry (e.g. application/xml-only), matching the + # fallback _extract_outputs already does for response bodies. + if json_schema is None and isinstance(content, dict): + for candidate_media_type, candidate_media_type_obj in content.items(): + if isinstance(candidate_media_type_obj, dict) and "schema" in candidate_media_type_obj: + media_type_obj = candidate_media_type_obj + json_schema = candidate_media_type_obj.get("schema") + body_content_type = candidate_media_type + break + json_schema = self._resolve_ref_obj(json_schema, set()) if json_schema else None if json_schema: body_examples = self._merge_examples(media_type_obj, json_schema) @@ -686,11 +706,15 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch prop["examples"] = body_examples properties[body_field] = prop - if json_schema.get("required"): + # requestBody.required (a bool on the request body itself) governs whether + # the body is a mandatory tool input; json_schema.get("required") is a + # different thing (the list of the body object's own required properties) + # and says nothing about whether the body as a whole may be omitted. + if request_body.get("required"): required.append(body_field) schema = JsonSchema(properties=properties, required=required if required else None) - return schema, header_fields, body_field + return schema, header_fields, body_field, body_content_type def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema: """Extracts the output schema from an OpenAPI operation, resolving refs.""" diff --git a/plugins/communication_protocols/http/tests/test_openapi_converter.py b/plugins/communication_protocols/http/tests/test_openapi_converter.py index 30fdebf..17c99aa 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -423,3 +423,133 @@ def test_openapi_converter_example_dedup_is_type_aware_and_order_insensitive(): assert tool.inputs.properties.get("body").examples == [{"a": 1, "b": 2}] # True vs 1 kept distinct (== would have collapsed them); duplicate True removed assert tool.outputs.examples == [True, 1] + + +def test_openapi_converter_request_body_falls_back_to_first_schema_bearing_media_type(): + """A requestBody declared only in a non-JSON media type must not be dropped. + + _extract_inputs hard-coded content["application/json"] with no fallback, unlike + _extract_outputs which already falls back to the first schema-bearing media type. + A body declared only as e.g. application/xml produced a tool with no body input at all. + """ + openapi_spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "post": { + "operationId": "createItem", + "requestBody": { + "content": { + "application/xml": { + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + "example": {"name": "widget"}, + } + } + }, + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + tool = next((t for t in manual.tools if t.name == "createItem"), None) + assert tool is not None + + body_param = tool.inputs.properties.get("body") + assert body_param is not None + assert body_param.properties.get("name").type == "string" + assert body_param.examples == [{"name": "widget"}] + # The fallback media type must reach the call template, or the tool would still + # send this body as application/json and JSON-encode an XML payload. + assert tool.tool_call_template.content_type == "application/xml" + + +def test_openapi_converter_request_body_required_flag_is_the_outer_flag(): + """requestBody.required (the outer flag) must drive the tool's own required list, + not json_schema.get("required") (the body object's own required properties). + + A requestBody marked required=true whose schema has no property-level "required" + list previously left the "body" tool input optional, even though OpenAPI says the + body itself may not be omitted. + """ + openapi_spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "post": { + "operationId": "createItem", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + } + }, + }, + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + tool = next((t for t in manual.tools if t.name == "createItem"), None) + assert tool is not None + assert tool.inputs.required == ["body"] + + +def test_openapi_converter_parameter_content_form_schema_and_examples(): + """A Parameter Object may carry its schema under 'content' instead of 'schema'. + + _extract_inputs only read param["schema"], so a parameter declared with the OAS3 + content form (content: {: {schema, example}}) lost both its schema and + its examples. + """ + openapi_spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "parameters": [ + { + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + "example": {"status": "active"}, + } + }, + } + ], + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + tool = next((t for t in manual.tools if t.name == "listItems"), None) + assert tool is not None + + filter_param = tool.inputs.properties.get("filter") + assert filter_param is not None + assert filter_param.properties.get("status").type == "string" + assert filter_param.examples == [{"status": "active"}]