#![cfg(feature = "openapi")]
use phi_core::openapi::{OpenApiConfig, OpenApiToolAdapter, OperationFilter};
use phi_core::types::{AgentTool, ToolContext};
const SPEC: &str = r#"{
"openapi": "3.0.0",
"info": { "title": "Test API", "version": "1.0.0" },
"servers": [{ "url": "https://api.example.com/v1" }],
"paths": {
"/items": {
"get": {
"operationId": "listItems",
"summary": "List all items",
"description": "Returns a paginated list of items.",
"tags": ["items"],
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": { "type": "integer" }
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": { "type": "integer" }
}
],
"responses": { "200": { "description": "A list of items" } }
},
"post": {
"operationId": "createItem",
"summary": "Create an item",
"tags": ["items"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"price": { "type": "number" }
},
"required": ["name"]
}
}
}
},
"responses": { "201": { "description": "Item created" } }
}
},
"/items/{itemId}": {
"get": {
"operationId": "getItem",
"summary": "Get an item",
"tags": ["items"],
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": { "200": { "description": "An item" } }
}
},
"/users": {
"get": {
"operationId": "listUsers",
"summary": "List users",
"tags": ["users"],
"parameters": [
{
"name": "X-Request-Id",
"in": "header",
"required": false,
"schema": { "type": "string" }
}
],
"responses": { "200": { "description": "A list of users" } }
}
}
}
}"#;
fn test_ctx() -> ToolContext {
ToolContext {
tool_call_id: "tc-1".into(),
tool_name: "test".into(),
cancel: tokio_util::sync::CancellationToken::new(),
on_update: None,
on_progress: None,
}
}
#[test]
fn test_adapter_creation() {
let adapters =
OpenApiToolAdapter::from_str(SPEC, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
assert_eq!(adapters.len(), 4);
}
#[test]
fn test_tool_names() {
let adapters =
OpenApiToolAdapter::from_str(SPEC, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"listItems"));
assert!(names.contains(&"createItem"));
assert!(names.contains(&"getItem"));
assert!(names.contains(&"listUsers"));
}
#[test]
fn test_tool_description() {
let adapters =
OpenApiToolAdapter::from_str(SPEC, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
assert_eq!(list_items.label(), "List all items");
assert_eq!(
list_items.description(),
"Returns a paginated list of items."
);
}
#[test]
fn test_schema_output() {
let adapters =
OpenApiToolAdapter::from_str(SPEC, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let get_item = adapters.iter().find(|a| a.name() == "getItem").unwrap();
let schema = get_item.parameters_schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["itemId"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&serde_json::json!("itemId")));
}
#[test]
fn test_filter_by_tag() {
let filter = OperationFilter::ByTag(vec!["users".into()]);
let adapters = OpenApiToolAdapter::from_str(SPEC, OpenApiConfig::default(), &filter).unwrap();
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].name(), "listUsers");
}
#[test]
fn test_name_prefix() {
let config = OpenApiConfig::default().with_name_prefix("myapi");
let adapters = OpenApiToolAdapter::from_str(SPEC, config, &OperationFilter::All).unwrap();
assert!(adapters.iter().all(|a| a.name().starts_with("myapi__")));
}
fn make_spec(base_url: &str) -> String {
format!(
r#"{{
"openapi": "3.0.0",
"info": {{ "title": "Test", "version": "1.0.0" }},
"servers": [{{ "url": "{base_url}" }}],
"paths": {{
"/items": {{
"get": {{
"operationId": "listItems",
"parameters": [
{{ "name": "limit", "in": "query", "schema": {{ "type": "integer" }} }},
{{ "name": "X-Trace", "in": "header", "schema": {{ "type": "string" }} }}
],
"responses": {{ "200": {{ "description": "ok" }} }}
}},
"post": {{
"operationId": "createItem",
"requestBody": {{
"required": true,
"content": {{
"application/json": {{
"schema": {{ "type": "object", "properties": {{ "name": {{ "type": "string" }} }} }}
}}
}}
}},
"responses": {{ "201": {{ "description": "created" }} }}
}}
}},
"/items/{{itemId}}": {{
"get": {{
"operationId": "getItem",
"parameters": [
{{ "name": "itemId", "in": "path", "required": true, "schema": {{ "type": "string" }} }}
],
"responses": {{ "200": {{ "description": "ok" }} }}
}}
}}
}}
}}"#
)
}
#[tokio::test]
async fn test_execute_get_with_path_param() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items/42"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id":"42"}"#))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let get_item = adapters.iter().find(|a| a.name() == "getItem").unwrap();
let result = get_item
.execute(serde_json::json!({"itemId": "42"}), test_ctx())
.await
.unwrap();
let text = match &result.content[0] {
phi_core::types::Content::Text { text } => text,
_ => panic!("Expected text content"),
};
assert!(text.contains("200"), "Should contain status 200");
assert!(
text.contains(r#"{"id":"42"}"#),
"Should contain response body"
);
assert_eq!(result.details["status"], 200);
}
#[tokio::test]
async fn test_execute_get_with_query_params() {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.and(query_param("limit", "10"))
.respond_with(ResponseTemplate::new(200).set_body_string("[]"))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
let result = list_items
.execute(serde_json::json!({"limit": 10}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 200);
}
#[tokio::test]
async fn test_execute_post_with_body() {
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/items"))
.and(body_json(serde_json::json!({"name": "Widget"})))
.respond_with(ResponseTemplate::new(201).set_body_string(r#"{"id":"1","name":"Widget"}"#))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let create_item = adapters.iter().find(|a| a.name() == "createItem").unwrap();
let result = create_item
.execute(serde_json::json!({"body": {"name": "Widget"}}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 201);
}
#[tokio::test]
async fn test_execute_with_bearer_auth() {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.and(header("Authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let config = OpenApiConfig::default().with_bearer_token("test-token");
let adapters = OpenApiToolAdapter::from_str(&spec, config, &OperationFilter::All).unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
let result = list_items
.execute(serde_json::json!({}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 200);
}
#[tokio::test]
async fn test_execute_with_api_key_auth() {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.and(header("X-API-Key", "my-key"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let config = OpenApiConfig::default().with_api_key("X-API-Key", "my-key");
let adapters = OpenApiToolAdapter::from_str(&spec, config, &OperationFilter::All).unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
let result = list_items
.execute(serde_json::json!({}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 200);
}
#[tokio::test]
async fn test_execute_with_custom_headers() {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.and(header("X-Custom", "custom-value"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let config = OpenApiConfig::default().with_header("X-Custom", "custom-value");
let adapters = OpenApiToolAdapter::from_str(&spec, config, &OperationFilter::All).unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
let result = list_items
.execute(serde_json::json!({}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 200);
}
#[tokio::test]
async fn test_execute_with_header_param() {
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.and(header("X-Trace", "trace-123"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
let result = list_items
.execute(serde_json::json!({"X-Trace": "trace-123"}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 200);
}
#[tokio::test]
async fn test_execute_non_2xx_returns_ok() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items/999"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let get_item = adapters.iter().find(|a| a.name() == "getItem").unwrap();
let result = get_item
.execute(serde_json::json!({"itemId": "999"}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 404);
let text = match &result.content[0] {
phi_core::types::Content::Text { text } => text,
_ => panic!("Expected text"),
};
assert!(text.contains("Not found"));
}
#[tokio::test]
async fn test_execute_response_truncation() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let long_body = "x".repeat(1000);
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(ResponseTemplate::new(200).set_body_string(&long_body))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let config = OpenApiConfig::default().with_max_response_bytes(100);
let adapters = OpenApiToolAdapter::from_str(&spec, config, &OperationFilter::All).unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
let result = list_items
.execute(serde_json::json!({}), test_ctx())
.await
.unwrap();
let text = match &result.content[0] {
phi_core::types::Content::Text { text } => text,
_ => panic!("Expected text"),
};
assert!(text.contains("[truncated]"));
assert!(text.len() < 500);
}
#[tokio::test]
async fn test_execute_missing_path_param_errors() {
use wiremock::MockServer;
let server = MockServer::start().await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let get_item = adapters.iter().find(|a| a.name() == "getItem").unwrap();
let result = get_item
.execute(serde_json::json!({}), test_ctx())
.await
.unwrap();
let text = match &result.content[0] {
phi_core::types::Content::Text { text } => text,
_ => panic!("Expected text"),
};
assert!(text.contains("Missing required path parameter"));
assert!(text.contains("itemId"));
}
#[tokio::test]
async fn test_execute_rejects_non_object_params() {
use wiremock::MockServer;
let server = MockServer::start().await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let list_items = adapters.iter().find(|a| a.name() == "listItems").unwrap();
let result = list_items
.execute(serde_json::json!("not an object"), test_ctx())
.await
.unwrap();
let text = match &result.content[0] {
phi_core::types::Content::Text { text } => text,
_ => panic!("Expected text"),
};
assert!(text.contains("string"));
}
#[tokio::test]
async fn test_execute_path_param_url_encoded() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items/a%2Fb"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let spec = make_spec(&server.uri());
let adapters =
OpenApiToolAdapter::from_str(&spec, OpenApiConfig::default(), &OperationFilter::All)
.unwrap();
let get_item = adapters.iter().find(|a| a.name() == "getItem").unwrap();
let result = get_item
.execute(serde_json::json!({"itemId": "a/b"}), test_ctx())
.await
.unwrap();
assert_eq!(result.details["status"], 200);
}