use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::Result;
use crate::http::HttpClient;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ConnectorSpec {
pub name: String,
pub display_name: String,
pub category: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ConnectorAction {
pub name: String,
pub display_name: String,
pub description: String,
pub param_schema: Value,
pub required: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthorizeResponse {
pub authorize_url: String,
pub state: String,
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
pub struct Connectors<'a> {
http: &'a HttpClient,
}
impl<'a> Connectors<'a> {
pub(crate) fn new(http: &'a HttpClient) -> Self {
Self { http }
}
pub async fn list(&self) -> Result<Vec<ConnectorSpec>> {
let body = self.http._get("/axtion/connectors", None).await?;
parse_a2ui_table(&body).ok_or_else(|| crate::error::AreevError::Other {
http_status: 0,
code: Some("SDK-E020".into()),
message: format!("connectors.list: response is not an A2UI data_table: {body}"),
body: serde_json::Value::Null,
request_id: None,
})
}
pub async fn get(&self, name: &str) -> Result<Value> {
let path = format!("/axtion/connectors/{name}");
self.http._get(&path, None).await
}
pub async fn actions(&self, name: &str) -> Result<Vec<ConnectorAction>> {
let detail = self.get(name).await?;
Ok(parse_action_descriptors(&detail))
}
pub async fn authorize(&self, name: &str, redirect_uri: &str) -> Result<AuthorizeResponse> {
let path = format!("/axtion/oauth/{name}/authorize");
let query = json!({ "redirect_uri": redirect_uri });
let body = self.http._get(&path, Some(&query)).await?;
normalize_authorize(body)
}
pub async fn poll_oauth(&self, name: &str, state: &str) -> Result<Value> {
let path = format!("/axtion/oauth/{name}/state/{state}");
self.http._get(&path, None).await
}
pub async fn store_credentials(&self, body: Value) -> Result<Value> {
self.http._post("/axtion/credentials", Some(&body)).await
}
}
fn column_field(raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().as_str() {
"name" => Some("name"),
"display name" => Some("display_name"),
"category" => Some("category"),
"version" => Some("version"),
_ => None,
}
}
fn parse_a2ui_table(body: &Value) -> Option<Vec<ConnectorSpec>> {
let components = body.get("a2ui")?.get("components")?.as_array()?;
for comp in components {
let (Some(columns), Some(rows)) = (
comp.get("columns").and_then(Value::as_array),
comp.get("rows").and_then(Value::as_array),
) else {
continue;
};
let keys: Vec<Option<&'static str>> = columns
.iter()
.map(|c| c.as_str().and_then(column_field))
.collect();
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let Some(cells) = row.as_array() else {
continue;
};
let mut spec = ConnectorSpec {
name: String::new(),
display_name: String::new(),
category: String::new(),
version: String::new(),
};
for (i, key) in keys.iter().enumerate() {
let Some(field) = key else { continue };
let val = cells
.get(i)
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
match *field {
"name" => spec.name = val,
"display_name" => spec.display_name = val,
"category" => spec.category = val,
"version" => spec.version = val,
_ => {}
}
}
out.push(spec);
}
return Some(out);
}
None
}
fn parse_action_descriptors(detail: &Value) -> Vec<ConnectorAction> {
let raw = detail
.get("data")
.and_then(|d| d.get("actions").or_else(|| d.get("tools")))
.or_else(|| detail.get("actions").or_else(|| detail.get("tools")))
.and_then(Value::as_array);
let Some(raw) = raw else {
return Vec::new();
};
raw.iter()
.filter_map(|d| {
let obj = d.as_object()?;
let schema = obj
.get("input_schema")
.or_else(|| obj.get("param_schema"))
.or_else(|| obj.get("inputSchema"))
.cloned()
.unwrap_or_else(|| json!({}));
let required = schema
.get("required")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let key = obj
.get("key")
.or_else(|| obj.get("name"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
Some(ConnectorAction {
name: key,
display_name: obj
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
description: obj
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
param_schema: schema,
required,
})
})
.collect()
}
fn normalize_authorize(body: Value) -> Result<AuthorizeResponse> {
let mut merged = serde_json::Map::new();
if let Some(top) = body.as_object() {
for (k, v) in top {
if k == "data" {
if let Some(inner) = v.as_object() {
for (ik, iv) in inner {
merged.insert(ik.clone(), iv.clone());
}
}
} else {
merged.insert(k.clone(), v.clone());
}
}
}
let authorize_url = merged
.get("authorize_url")
.or_else(|| merged.get("oauth_url"))
.or_else(|| merged.get("url"))
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| crate::error::AreevError::Other {
http_status: 0,
code: Some("SDK-E021".into()),
message: format!(
"connectors.authorize: response has no oauth_url/authorize_url: {body}"
),
body: serde_json::Value::Null,
request_id: None,
})?;
let state = merged
.get("state")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
merged.remove("authorize_url");
merged.remove("oauth_url");
merged.remove("url");
merged.remove("state");
Ok(AuthorizeResponse {
authorize_url,
state,
extra: merged,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn gmail_detail_fixture() -> Value {
json!({
"data": {
"connector": "gmail",
"actions": [
{
"connector": "gmail",
"key": "send-email",
"name": "Send Email",
"description": "Send an email on the user's behalf.",
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"}
},
"required": ["to", "subject", "body"]
},
"annotations": {}
}
]
}
})
}
#[test]
fn parses_a2ui_catalog_table_case_insensitive() {
let body = json!({
"a2ui": {
"components": [
{
"columns": ["Name", "Display Name", "Category", "Version"],
"rows": [
["gmail", "Google Gmail", "Communication", "1.0.0"],
["google-calendar", "Google Calendar", "Productivity", "2.1.0"]
]
}
]
}
});
let specs = parse_a2ui_table(&body).expect("parses table");
assert_eq!(specs.len(), 2);
assert_eq!(
specs[0],
ConnectorSpec {
name: "gmail".into(),
display_name: "Google Gmail".into(),
category: "Communication".into(),
version: "1.0.0".into(),
}
);
assert_eq!(specs[1].name, "google-calendar");
assert_eq!(specs[1].version, "2.1.0");
}
#[test]
fn a2ui_parser_returns_none_for_non_table_body() {
assert!(parse_a2ui_table(&json!({"unexpected": "shape"})).is_none());
assert!(parse_a2ui_table(&json!({"a2ui": {"components": []}})).is_none());
}
#[test]
fn actions_parses_gmail_send_email_with_required() {
let actions = parse_action_descriptors(&gmail_detail_fixture());
assert_eq!(actions.len(), 1);
let send = &actions[0];
assert_eq!(send.name, "send-email", "name is the action key");
assert_eq!(send.display_name, "Send Email");
assert_eq!(send.description, "Send an email on the user's behalf.");
assert_eq!(
send.required,
vec!["to".to_string(), "subject".to_string(), "body".to_string()]
);
assert_eq!(send.param_schema["type"], "object");
assert!(send.param_schema["properties"]["subject"].is_object());
}
#[test]
fn actions_falls_back_to_data_tools() {
let detail = json!({
"data": {
"tools": [
{"key": "lookup", "name": "Lookup", "input_schema": {"type": "object"}}
]
}
});
let actions = parse_action_descriptors(&detail);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].name, "lookup");
assert!(actions[0].required.is_empty());
}
#[test]
fn actions_empty_for_unknown_shape() {
assert!(parse_action_descriptors(&json!({"nope": true})).is_empty());
}
#[test]
fn normalize_authorize_lifts_oauth_url_and_state() {
let body = json!({
"data": {"oauth_url": "https://accounts.google.com/o/oauth2/auth?x=1"},
"state": "st_abc123"
});
let resp = normalize_authorize(body).expect("normalizes");
assert_eq!(
resp.authorize_url,
"https://accounts.google.com/o/oauth2/auth?x=1"
);
assert_eq!(resp.state, "st_abc123");
assert!(!resp.extra.contains_key("oauth_url"));
assert!(!resp.extra.contains_key("state"));
}
#[test]
fn normalize_authorize_errors_without_url() {
let err = normalize_authorize(json!({"state": "x"})).unwrap_err();
assert_eq!(err.code(), Some("SDK-E021"));
}
}