mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
feat: add native Composio tool for third-party app integrations (#920)
* feat: add Composio WASM tool for third-party app integrations Add Composio integration as a WASM tool (tools-src/composio/), providing a single multiplexed tool with 4 actions: list, execute, connect, and connected_accounts. Supports 250+ third-party apps via Composio's REST API with WASM sandbox security (fuel metering, memory limits, network allowlisting, host-injected credentials). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review — retry safety, dead code, registry manifest Code fixes (tools-src/composio/src/lib.rs): - Only retry GET requests (idempotent); POST executes once to prevent duplicate side effects on execute/connect actions - Remove dead parse_json_response status check (already handled by caller); use serde_json::from_slice to avoid extra allocation - Remove misleading secret_exists pre-flight (only checks capability allowlist, not actual presence); instead surface helpful error on 401/403 from the API - Extract entity_id logic into extract_entity_id() helper with 6 unit tests covering precedence chain and edge cases Registry: - Add registry/tools/composio.json manifest (matches format of other tools like web-search, github, gmail) - Add composio to the default bundle in _bundles.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore secret pre-flight, enforce schema, remove default tag - Restore secret_exists pre-flight as best-effort check (avoids wasting rate-limited API calls when clearly misconfigured) - Add #[serde(deny_unknown_fields)] to Params to match the schema's additionalProperties: false contract - Remove "default" tag from registry manifest and remove from default bundle until WASM artifacts are published Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: simplify entity_id fallback, add params type to schema - Remove requester_id fallback from extract_entity_id (user_id is always present in JobContext, so requester_id was dead code) - Add "type": "object" to params field in both tool schema and capabilities.json to prevent schema-driven callers from sending non-object values Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: align with Composio v3 API contract + fixture tests Address serrrfirat's review — update all response parsing and request fields to match the current Composio v3 API: - Add unwrap_items() helper for paginated { "items": [...] } envelopes, with bare-array fallback for backward compatibility - connect_app: parse auth_configs from paginated response via extract_auth_config_id() - execute_action: use v3 fields `user_id` + `arguments` (not deprecated `entity_id` + `input`) - list_accounts/resolve_account: use plural query params `user_ids`, `toolkit_slugs` (v3 contract) - lookup_app_for_tool: look for nested `toolkit.slug` (v3), falling back to `toolkit_slug` and `appName` - find_active_account: sort by `updated_at` (v3), falling back to `updatedAt` Add 15 fixture-style tests covering: - Paginated envelope parsing (envelope, bare array, empty, non-array) - Auth config extraction (paginated, bare, empty) - Toolkit slug extraction (v3 nested, legacy flat, appName fallback, case-insensitive, not-found) - Active account selection (v3 timestamps, legacy timestamps, no active) Total: 25 tests (5 URL, 5 entity_id, 15 v3 contract fixtures) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — remove duplicate parameters, validate params, fix ordering - Remove `parameters` section from capabilities JSON (duplicates SCHEMA const, runtime ignores it, creates drift risk) - Fix Cargo.toml exclude ordering: tools-src/composio before tools-src/github - Validate `params` is a JSON object when provided, reject non-object values early - Remove 429 from retry logic (WASM has no sleep/backoff, immediate retry wastes rate-limit budget) — only retry on transient 5xx - Add tests for params validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address maintainer review — retry convention, numeric IDs, slug validation - Revert 429 retry to align with github/web-search tool convention (sub-second sliding-window resets can make immediate retries worthwhile) - Handle numeric entity_id/user_id in context JSON (as_u64/as_i64 fallback) - Add validate_tool_slug() defense-in-depth against path traversal (same pattern as github tool) - Add tests for numeric entity IDs and slug validation (32 total) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address 4 unresolved audit issues — pagination, direct lookup, array params 1. list_tools: expose cursor/limit params in schema, preserve next_cursor and total in response for multi-page browsing, add toolkit_versions=latest 2. lookup_app_for_tool: use direct GET /tools/{slug} endpoint instead of fuzzy search (avoids false negatives from search pagination/ranking), add toolkit_versions=latest 3. connected_accounts queries: encode user_ids and toolkit_slugs as array params (user_ids[], toolkit_slugs[]) per v3 API contract 4. toolkit_versions=latest added to both list and lookup endpoints Adds 5 new tests (37 total): cursor/limit params, array query encoding, direct tool response parsing (v3 nested, legacy, missing). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ilblackdragon@gmail.com <ilblackdragon@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: firat.sertgoz <f@nuff.tech>
This commit is contained in:
@@ -5,6 +5,7 @@ exclude = [
|
||||
"channels-src/telegram",
|
||||
"channels-src/slack",
|
||||
"channels-src/whatsapp",
|
||||
"tools-src/composio",
|
||||
"tools-src/github",
|
||||
"tools-src/gmail",
|
||||
"tools-src/google-calendar",
|
||||
|
||||
33
registry/tools/composio.json
Normal file
33
registry/tools/composio.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "composio",
|
||||
"display_name": "Composio",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, Jira, etc.) via Composio",
|
||||
"keywords": [
|
||||
"composio",
|
||||
"integrations",
|
||||
"oauth",
|
||||
"automation",
|
||||
"apps"
|
||||
],
|
||||
"source": {
|
||||
"dir": "tools-src/composio",
|
||||
"capabilities": "composio-tool.capabilities.json",
|
||||
"crate_name": "composio-tool"
|
||||
},
|
||||
"artifacts": {},
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Composio",
|
||||
"secrets": [
|
||||
"composio_api_key"
|
||||
],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://app.composio.dev/"
|
||||
},
|
||||
"tags": [
|
||||
"integrations"
|
||||
]
|
||||
}
|
||||
23
tools-src/composio/Cargo.toml
Normal file
23
tools-src/composio/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "composio-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Composio integration tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
48
tools-src/composio/composio-tool.capabilities.json
Normal file
48
tools-src/composio/composio-tool.capabilities.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. Actions: list (browse tools), execute (run a tool), connect (OAuth-link an app), connected_accounts (list linked accounts). Authentication is handled via the 'composio_api_key' secret injected by the host.",
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "backend.composio.dev",
|
||||
"path_prefix": "/api/v3/",
|
||||
"methods": ["GET", "POST"]
|
||||
}
|
||||
],
|
||||
"credentials": {
|
||||
"composio_api_key": {
|
||||
"secret_name": "composio_api_key",
|
||||
"location": {
|
||||
"type": "header",
|
||||
"name": "x-api-key"
|
||||
},
|
||||
"host_patterns": ["backend.composio.dev"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 30,
|
||||
"requests_per_hour": 500
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["composio_api_key"]
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"secret_name": "composio_api_key",
|
||||
"display_name": "Composio",
|
||||
"instructions": "Get an API key at app.composio.dev — go to Settings > API Keys to generate one.",
|
||||
"setup_url": "https://app.composio.dev/",
|
||||
"env_var": "COMPOSIO_API_KEY"
|
||||
},
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "composio_api_key",
|
||||
"prompt": "Composio API key (from app.composio.dev)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
899
tools-src/composio/src/lib.rs
Normal file
899
tools-src/composio/src/lib.rs
Normal file
@@ -0,0 +1,899 @@
|
||||
//! Composio WASM Tool for IronClaw.
|
||||
//!
|
||||
//! Connects to 250+ third-party apps via Composio's REST API (v3).
|
||||
//! Provides a single multiplexed tool with actions: list, execute, connect,
|
||||
//! connected_accounts.
|
||||
//!
|
||||
//! # Authentication
|
||||
//!
|
||||
//! Store your Composio API key:
|
||||
//! `ironclaw secret set composio_api_key <key>`
|
||||
//!
|
||||
//! Get a key at: https://app.composio.dev/
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const API_BASE: &str = "https://backend.composio.dev/api/v3";
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
struct ComposioTool;
|
||||
|
||||
impl exports::near::agent::tool::Guest for ComposioTool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params, req.context.as_deref()) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
SCHEMA.to_string()
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. \
|
||||
Actions: \"list\" (browse tools), \"execute\" (run a tool), \
|
||||
\"connect\" (OAuth-link an app), \"connected_accounts\" (list linked accounts). \
|
||||
Authentication is handled via the 'composio_api_key' secret injected by the host."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Params {
|
||||
action: String,
|
||||
app: Option<String>,
|
||||
tool_slug: Option<String>,
|
||||
params: Option<serde_json::Value>,
|
||||
connected_account_id: Option<String>,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
fn execute_inner(params_str: &str, context: Option<&str>) -> Result<String, String> {
|
||||
let params: Params =
|
||||
serde_json::from_str(params_str).map_err(|e| format!("Invalid parameters: {e}"))?;
|
||||
|
||||
if params.action.is_empty() {
|
||||
return Err("'action' must not be empty".into());
|
||||
}
|
||||
|
||||
// Best-effort pre-flight: check if the secret is configured in capabilities.
|
||||
// This won't catch every case (the host may only check the allowlist), but
|
||||
// avoids wasting a rate-limited API call when clearly misconfigured.
|
||||
if !near::agent::host::secret_exists("composio_api_key") {
|
||||
return Err(
|
||||
"Composio API key not configured. Set it with: \
|
||||
ironclaw secret set composio_api_key <key>. \
|
||||
Get a key at: https://app.composio.dev/"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let entity_id = extract_entity_id(context);
|
||||
|
||||
// Validate params is an object when provided (schema declares it as such).
|
||||
if let Some(ref p) = params.params {
|
||||
if !p.is_object() {
|
||||
return Err("'params' must be a JSON object".into());
|
||||
}
|
||||
}
|
||||
|
||||
match params.action.as_str() {
|
||||
"list" => list_tools(params.app.as_deref(), params.cursor.as_deref(), params.limit),
|
||||
"execute" => {
|
||||
let tool_slug = params
|
||||
.tool_slug
|
||||
.as_deref()
|
||||
.ok_or("missing 'tool_slug' for execute action")?;
|
||||
validate_tool_slug(tool_slug)?;
|
||||
let action_params = params.params.unwrap_or(serde_json::json!({}));
|
||||
execute_action(
|
||||
tool_slug,
|
||||
&action_params,
|
||||
&entity_id,
|
||||
params.connected_account_id.as_deref(),
|
||||
)
|
||||
}
|
||||
"connect" => {
|
||||
let app = params
|
||||
.app
|
||||
.as_deref()
|
||||
.ok_or("missing 'app' for connect action")?;
|
||||
connect_app(app, &entity_id)
|
||||
}
|
||||
"connected_accounts" => list_accounts(params.app.as_deref(), &entity_id),
|
||||
other => Err(format!(
|
||||
"unknown action \"{other}\", expected: list, execute, connect, connected_accounts"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn api_get(path: &str, query: &[(&str, &str)]) -> Result<serde_json::Value, String> {
|
||||
let url = build_url(path, query);
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "IronClaw-Composio-Tool/0.1"
|
||||
});
|
||||
|
||||
let response = get_with_retry(&url, &headers.to_string())?;
|
||||
parse_json_body(&response.body)
|
||||
}
|
||||
|
||||
fn api_post(path: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let url = build_url(path, &[]);
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "IronClaw-Composio-Tool/0.1"
|
||||
});
|
||||
|
||||
let body_bytes = serde_json::to_vec(body).map_err(|e| format!("JSON serialize error: {e}"))?;
|
||||
|
||||
// POST is not idempotent — no retry to avoid duplicate side effects.
|
||||
let resp = near::agent::host::http_request("POST", &url, &headers.to_string(), Some(&body_bytes), None)
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
|
||||
if resp.status >= 200 && resp.status < 300 {
|
||||
return parse_json_body(&resp.body);
|
||||
}
|
||||
|
||||
// Surface helpful message on auth failure
|
||||
if resp.status == 401 || resp.status == 403 {
|
||||
return Err(
|
||||
"Composio API authentication failed. Ensure your API key is set: \
|
||||
ironclaw secret set composio_api_key <key>. \
|
||||
Get a key at: https://app.composio.dev/"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let truncated_bytes = if resp.body.len() > 512 { &resp.body[..512] } else { &resp.body };
|
||||
let truncated = String::from_utf8_lossy(truncated_bytes);
|
||||
Err(format!("Composio API error (HTTP {}): {truncated}", resp.status))
|
||||
}
|
||||
|
||||
/// GET with retry on transient errors (429, 5xx). Safe to retry since GET is idempotent.
|
||||
fn get_with_retry(
|
||||
url: &str,
|
||||
headers: &str,
|
||||
) -> Result<near::agent::host::HttpResponse, String> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
let resp = near::agent::host::http_request("GET", url, headers, None, None)
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
|
||||
if resp.status >= 200 && resp.status < 300 {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// Surface helpful message on auth failure
|
||||
if resp.status == 401 || resp.status == 403 {
|
||||
return Err(
|
||||
"Composio API authentication failed. Ensure your API key is set: \
|
||||
ironclaw secret set composio_api_key <key>. \
|
||||
Get a key at: https://app.composio.dev/"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Retry on 429 and 5xx to align with github/web-search tool convention.
|
||||
// NOTE: The WASM host has no sleep primitive, so retries are immediate.
|
||||
// This still helps when a sliding-window rate limiter resets between
|
||||
// the original request and the retry (sub-second windows are common).
|
||||
if attempt < MAX_RETRIES && (resp.status == 429 || resp.status >= 500) {
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Warn,
|
||||
&format!(
|
||||
"Composio API error {} (attempt {}/{}). Retrying...",
|
||||
resp.status, attempt, MAX_RETRIES
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Truncate at byte level before UTF-8 conversion to avoid
|
||||
// panicking on multibyte character boundaries.
|
||||
let truncated_bytes = if resp.body.len() > 512 {
|
||||
&resp.body[..512]
|
||||
} else {
|
||||
&resp.body
|
||||
};
|
||||
let truncated = String::from_utf8_lossy(truncated_bytes);
|
||||
return Err(format!("Composio API error (HTTP {}): {truncated}", resp.status));
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a JSON response body directly from bytes (avoids extra allocation).
|
||||
fn parse_json_body(body: &[u8]) -> Result<serde_json::Value, String> {
|
||||
serde_json::from_slice(body).map_err(|e| format!("invalid JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Unwrap a paginated v3 response.
|
||||
///
|
||||
/// The Composio v3 API returns paginated results as `{ "items": [...] }`.
|
||||
/// This helper extracts the `items` array, falling back to treating the
|
||||
/// response as a bare array for backward compatibility.
|
||||
fn unwrap_items(value: &serde_json::Value) -> Option<&Vec<serde_json::Value>> {
|
||||
// v3 paginated envelope: { "items": [...] }
|
||||
value
|
||||
.get("items")
|
||||
.and_then(|v| v.as_array())
|
||||
// Fallback: bare array (older or non-paginated endpoints)
|
||||
.or_else(|| value.as_array())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn list_tools(app: Option<&str>, cursor: Option<&str>, limit: Option<u32>) -> Result<String, String> {
|
||||
let limit_str = limit.map(|l| l.to_string());
|
||||
let mut query: Vec<(&str, &str)> = vec![("toolkit_versions", "latest")];
|
||||
if let Some(a) = app {
|
||||
query.push(("toolkit_slug", a));
|
||||
}
|
||||
if let Some(ref c) = cursor {
|
||||
query.push(("cursor", c));
|
||||
}
|
||||
if let Some(ref l) = limit_str {
|
||||
query.push(("limit", l));
|
||||
}
|
||||
let result = api_get("/tools", &query)?;
|
||||
|
||||
// Preserve pagination metadata (next_cursor, total) alongside items
|
||||
// so callers can request subsequent pages.
|
||||
let items = unwrap_items(&result).cloned().unwrap_or_default();
|
||||
let mut output = serde_json::json!({ "items": items });
|
||||
if let Some(next_cursor) = result.get("next_cursor").and_then(|v| v.as_str()) {
|
||||
output["next_cursor"] = serde_json::json!(next_cursor);
|
||||
}
|
||||
if let Some(total) = result.get("total").and_then(|v| v.as_u64()) {
|
||||
output["total"] = serde_json::json!(total);
|
||||
}
|
||||
serde_json::to_string(&output).map_err(|e| format!("Failed to serialize output: {e}"))
|
||||
}
|
||||
|
||||
fn execute_action(
|
||||
tool_slug: &str,
|
||||
params: &serde_json::Value,
|
||||
entity_id: &str,
|
||||
connected_account_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
// Auto-resolve connected account if not provided
|
||||
let account_id = match connected_account_id {
|
||||
Some(id) => id.to_string(),
|
||||
None => resolve_account(tool_slug, entity_id)?,
|
||||
};
|
||||
|
||||
// v3 contract: `user_id` (not `entity_id`), `arguments` (not `input`).
|
||||
let body = serde_json::json!({
|
||||
"connected_account_id": account_id,
|
||||
"user_id": entity_id,
|
||||
"arguments": params,
|
||||
});
|
||||
let result = api_post(&format!("/tools/execute/{}", url_encode(tool_slug)), &body)?;
|
||||
serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}"))
|
||||
}
|
||||
|
||||
fn connect_app(app: &str, entity_id: &str) -> Result<String, String> {
|
||||
// Resolve auth config for this app — v3 returns paginated { "items": [...] }
|
||||
let configs = api_get("/auth_configs", &[("toolkit_slug", app)])?;
|
||||
let auth_config_id = extract_auth_config_id(&configs, app)?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"auth_config_id": auth_config_id,
|
||||
"user_id": entity_id,
|
||||
});
|
||||
let result = api_post("/connected_accounts/link", &body)?;
|
||||
serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}"))
|
||||
}
|
||||
|
||||
/// Extract the first auth config ID from a (possibly paginated) response.
|
||||
fn extract_auth_config_id(configs: &serde_json::Value, app: &str) -> Result<String, String> {
|
||||
unwrap_items(configs)
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|c| c.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
format!("no auth config found for {app} — configure it at app.composio.dev")
|
||||
})
|
||||
}
|
||||
|
||||
fn list_accounts(app: Option<&str>, entity_id: &str) -> Result<String, String> {
|
||||
// v3 documents these as array-valued params: `user_ids[]`, `toolkit_slugs[]`
|
||||
let mut query = vec![("user_ids[]", entity_id)];
|
||||
if let Some(a) = app {
|
||||
query.push(("toolkit_slugs[]", a));
|
||||
}
|
||||
let result = api_get("/connected_accounts", &query)?;
|
||||
let items = unwrap_items(&result).cloned().unwrap_or_default();
|
||||
serde_json::to_string(&items).map_err(|e| format!("Failed to serialize output: {e}"))
|
||||
}
|
||||
|
||||
/// Look up the toolkit/app slug for a tool via the Composio API.
|
||||
///
|
||||
/// Uses the direct `GET /tools/{tool_slug}` endpoint for exact lookup,
|
||||
/// avoiding false negatives from search pagination/ranking. Falls back
|
||||
/// to the search endpoint if the direct lookup returns a non-item shape.
|
||||
fn lookup_app_for_tool(tool_slug: &str) -> Result<String, String> {
|
||||
// Direct slug endpoint — exact match, no pagination concerns.
|
||||
let tool = api_get(
|
||||
&format!("/tools/{}", url_encode(tool_slug)),
|
||||
&[("toolkit_versions", "latest")],
|
||||
)?;
|
||||
extract_toolkit_slug_from_tool(&tool, tool_slug)
|
||||
}
|
||||
|
||||
/// Extract the toolkit slug from a single tool object (direct endpoint response).
|
||||
///
|
||||
/// v3 nests the toolkit slug under `toolkit.slug`; falls back to
|
||||
/// `toolkit_slug` or `appName` for backward compatibility.
|
||||
fn extract_toolkit_slug_from_tool(tool: &serde_json::Value, tool_slug: &str) -> Result<String, String> {
|
||||
tool.get("toolkit")
|
||||
.and_then(|tk| tk.get("slug"))
|
||||
.or_else(|| tool.get("toolkit_slug"))
|
||||
.or_else(|| tool.get("appName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.ok_or_else(|| {
|
||||
format!("could not determine app for tool \"{tool_slug}\" — verify the slug is correct")
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the toolkit slug from a paginated tools list response.
|
||||
///
|
||||
/// Scans the items array for an exact slug match (case-insensitive).
|
||||
fn extract_toolkit_slug(tools: &serde_json::Value, tool_slug: &str) -> Result<String, String> {
|
||||
let items = unwrap_items(tools).ok_or_else(|| {
|
||||
format!("could not determine app for tool \"{tool_slug}\" — unexpected response shape")
|
||||
})?;
|
||||
|
||||
let tool = items
|
||||
.iter()
|
||||
.find(|t| {
|
||||
t.get("slug")
|
||||
.and_then(|s| s.as_str())
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(tool_slug))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!("could not determine app for tool \"{tool_slug}\" — verify the slug is correct")
|
||||
})?;
|
||||
|
||||
extract_toolkit_slug_from_tool(tool, tool_slug)
|
||||
}
|
||||
|
||||
/// Auto-resolve connected account for a tool slug.
|
||||
fn resolve_account(tool_slug: &str, entity_id: &str) -> Result<String, String> {
|
||||
let app = lookup_app_for_tool(tool_slug)?;
|
||||
find_active_account(tool_slug, &app, entity_id)
|
||||
}
|
||||
|
||||
/// Find the most recently updated active connected account.
|
||||
///
|
||||
/// v3 uses `updated_at` (not `updatedAt`) and returns paginated items.
|
||||
fn find_active_account(tool_slug: &str, app: &str, entity_id: &str) -> Result<String, String> {
|
||||
// v3 documents these as array-valued params
|
||||
let accounts = api_get(
|
||||
"/connected_accounts",
|
||||
&[("user_ids[]", entity_id), ("toolkit_slugs[]", app)],
|
||||
)?;
|
||||
|
||||
let items = unwrap_items(&accounts).ok_or_else(|| {
|
||||
format!("no connected account for {app} — use composio with action=\"connect\" first")
|
||||
})?;
|
||||
|
||||
items
|
||||
.iter()
|
||||
.filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE"))
|
||||
.max_by_key(|a| {
|
||||
// v3: updated_at; fallback: updatedAt
|
||||
a.get("updated_at")
|
||||
.or_else(|| a.get("updatedAt"))
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
})
|
||||
.and_then(|a| a.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"no active connected account for {app} (tool: {tool_slug}) — \
|
||||
use composio with action=\"connect\" first"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Defense-in-depth: reject tool slugs that could cause path traversal.
|
||||
/// The WASM host allowlist already normalises paths and rejects `..` segments,
|
||||
/// but we validate here too (same pattern as the `github` tool).
|
||||
fn validate_tool_slug(s: &str) -> Result<(), String> {
|
||||
if s.is_empty() || s.contains("..") || s.contains('/') || s.contains('\\') {
|
||||
return Err(format!("invalid tool_slug: \"{s}\""));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_url(path: &str, query: &[(&str, &str)]) -> String {
|
||||
let mut url = format!("{API_BASE}{path}");
|
||||
if !query.is_empty() {
|
||||
url.push('?');
|
||||
for (i, (k, v)) in query.iter().enumerate() {
|
||||
if i > 0 {
|
||||
url.push('&');
|
||||
}
|
||||
url.push_str(&url_encode(k));
|
||||
url.push('=');
|
||||
url.push_str(&url_encode(v));
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// Percent-encode a string for safe use in URL query parameters.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 2);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
b' ' => out.push_str("%20"),
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const SCHEMA: &str = r#"{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "execute", "connect", "connected_accounts"],
|
||||
"description": "Action to perform"
|
||||
},
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "App/toolkit slug (e.g., \"gmail\", \"github\", \"notion\")"
|
||||
},
|
||||
"tool_slug": {
|
||||
"type": "string",
|
||||
"description": "Tool action slug for execute (e.g., \"GMAIL_SEND_EMAIL\")"
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"description": "Parameters for the tool action (JSON object)",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"connected_account_id": {
|
||||
"type": "string",
|
||||
"description": "Specific connected account ID (auto-resolved if omitted)"
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"description": "Pagination cursor for list action (from previous response's next_cursor)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max items per page for list action (default: API default ~20)"
|
||||
}
|
||||
},
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
}"#;
|
||||
|
||||
/// Extract an entity identifier from context JSON.
|
||||
///
|
||||
/// Checks `entity_id` first (explicit override), then `user_id` (from
|
||||
/// JobContext — always present in production), falling back to "default".
|
||||
fn extract_entity_id(context: Option<&str>) -> String {
|
||||
context
|
||||
.and_then(|ctx| serde_json::from_str::<serde_json::Value>(ctx).ok())
|
||||
.and_then(|v| {
|
||||
v.get("entity_id")
|
||||
.or_else(|| v.get("user_id"))
|
||||
.and_then(|e| {
|
||||
e.as_str()
|
||||
.map(String::from)
|
||||
.or_else(|| e.as_u64().map(|n| n.to_string()))
|
||||
.or_else(|| e.as_i64().map(|n| n.to_string()))
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "default".to_string())
|
||||
}
|
||||
|
||||
export!(ComposioTool);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_url_encode() {
|
||||
assert_eq!(url_encode("hello world"), "hello%20world");
|
||||
assert_eq!(url_encode("foo&bar=baz"), "foo%26bar%3Dbaz");
|
||||
assert_eq!(url_encode("simple"), "simple");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_encode_multibyte() {
|
||||
assert_eq!(url_encode("café"), "caf%C3%A9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_query() {
|
||||
let url = build_url("/tools", &[]);
|
||||
assert_eq!(url, format!("{API_BASE}/tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_with_query() {
|
||||
let url = build_url("/tools", &[("toolkit_slug", "gmail"), ("search", "send")]);
|
||||
assert!(url.starts_with(&format!("{API_BASE}/tools?")));
|
||||
assert!(url.contains("toolkit_slug=gmail"));
|
||||
assert!(url.contains("search=send"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_encodes_special_chars() {
|
||||
let url = build_url("/tools", &[("q", "my app+1")]);
|
||||
assert!(url.contains("q=my%20app%2B1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_entity_id_from_entity_id() {
|
||||
let ctx = r#"{"entity_id": "tenant-42", "user_id": "user-1"}"#;
|
||||
assert_eq!(extract_entity_id(Some(ctx)), "tenant-42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_entity_id_falls_back_to_user_id() {
|
||||
let ctx = r#"{"user_id": "user-1"}"#;
|
||||
assert_eq!(extract_entity_id(Some(ctx)), "user-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_entity_id_defaults_when_none() {
|
||||
assert_eq!(extract_entity_id(None), "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_entity_id_defaults_on_empty_context() {
|
||||
assert_eq!(extract_entity_id(Some("{}")), "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_entity_id_defaults_on_malformed_json() {
|
||||
assert_eq!(extract_entity_id(Some("not json")), "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_entity_id_numeric_user_id() {
|
||||
let ctx = r#"{"user_id": 12345}"#;
|
||||
assert_eq!(extract_entity_id(Some(ctx)), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_entity_id_numeric_entity_id() {
|
||||
let ctx = r#"{"entity_id": 99, "user_id": "user-1"}"#;
|
||||
assert_eq!(extract_entity_id(Some(ctx)), "99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_tool_slug_valid() {
|
||||
assert!(validate_tool_slug("GMAIL_SEND_EMAIL").is_ok());
|
||||
assert!(validate_tool_slug("slack-post").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_tool_slug_rejects_traversal() {
|
||||
assert!(validate_tool_slug("..").is_err());
|
||||
assert!(validate_tool_slug("foo/../bar").is_err());
|
||||
assert!(validate_tool_slug("foo/bar").is_err());
|
||||
assert!(validate_tool_slug("foo\\bar").is_err());
|
||||
assert!(validate_tool_slug("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_params_with_cursor_and_limit() {
|
||||
let p: Params = serde_json::from_str(
|
||||
r#"{"action": "list", "cursor": "abc123", "limit": 50}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.cursor.as_deref(), Some("abc123"));
|
||||
assert_eq!(p.limit, Some(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_query_param_encoding() {
|
||||
let url = build_url("/connected_accounts", &[("user_ids[]", "alice"), ("toolkit_slugs[]", "gmail")]);
|
||||
assert!(url.contains("user_ids%5B%5D=alice"));
|
||||
assert!(url.contains("toolkit_slugs%5B%5D=gmail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_from_direct_response() {
|
||||
let tool: serde_json::Value = serde_json::from_str(
|
||||
r#"{"slug": "GMAIL_SEND_EMAIL", "toolkit": {"slug": "gmail"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_toolkit_slug_from_tool(&tool, "GMAIL_SEND_EMAIL").unwrap(),
|
||||
"gmail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_from_direct_response_legacy() {
|
||||
let tool: serde_json::Value = serde_json::from_str(
|
||||
r#"{"slug": "SLACK_POST", "toolkit_slug": "slack"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_toolkit_slug_from_tool(&tool, "SLACK_POST").unwrap(),
|
||||
"slack"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_from_direct_response_missing() {
|
||||
let tool: serde_json::Value = serde_json::from_str(
|
||||
r#"{"slug": "UNKNOWN"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(extract_toolkit_slug_from_tool(&tool, "UNKNOWN").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_params_deserialization_rejects_unknown_fields() {
|
||||
let result: Result<Params, _> =
|
||||
serde_json::from_str(r#"{"action": "list", "bogus": true}"#);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_params_accepts_valid_object_params() {
|
||||
let p: Params =
|
||||
serde_json::from_str(r#"{"action": "execute", "params": {"key": "val"}}"#).unwrap();
|
||||
assert!(p.params.unwrap().is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_params_accepts_null_params() {
|
||||
let p: Params =
|
||||
serde_json::from_str(r#"{"action": "list"}"#).unwrap();
|
||||
assert!(p.params.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fixture-style tests for v3 API contract parsing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_items_paginated_envelope() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"{"items": [{"id": "1"}, {"id": "2"}], "total": 2, "page": 1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = unwrap_items(&resp).unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_items_bare_array_fallback() {
|
||||
let resp: serde_json::Value =
|
||||
serde_json::from_str(r#"[{"id": "1"}, {"id": "2"}]"#).unwrap();
|
||||
let items = unwrap_items(&resp).unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_items_empty_envelope() {
|
||||
let resp: serde_json::Value =
|
||||
serde_json::from_str(r#"{"items": [], "total": 0}"#).unwrap();
|
||||
let items = unwrap_items(&resp).unwrap();
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_items_non_array_returns_none() {
|
||||
let resp: serde_json::Value =
|
||||
serde_json::from_str(r#"{"error": "not found"}"#).unwrap();
|
||||
assert!(unwrap_items(&resp).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_config_id_from_paginated() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"{"items": [{"id": "ac-123", "type": "oauth2"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_auth_config_id(&resp, "gmail").unwrap(),
|
||||
"ac-123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_config_id_from_bare_array() {
|
||||
let resp: serde_json::Value =
|
||||
serde_json::from_str(r#"[{"id": "ac-456"}]"#).unwrap();
|
||||
assert_eq!(
|
||||
extract_auth_config_id(&resp, "github").unwrap(),
|
||||
"ac-456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_config_id_empty_items() {
|
||||
let resp: serde_json::Value =
|
||||
serde_json::from_str(r#"{"items": []}"#).unwrap();
|
||||
let err = extract_auth_config_id(&resp, "slack").unwrap_err();
|
||||
assert!(err.contains("no auth config found for slack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_v3_nested() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"{"items": [{"slug": "GMAIL_SEND_EMAIL", "toolkit": {"slug": "gmail"}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_toolkit_slug(&resp, "GMAIL_SEND_EMAIL").unwrap(),
|
||||
"gmail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_legacy_flat() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"[{"slug": "SLACK_POST", "toolkit_slug": "slack"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_toolkit_slug(&resp, "SLACK_POST").unwrap(),
|
||||
"slack"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_app_name_fallback() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"[{"slug": "NOTION_CREATE", "appName": "Notion"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_toolkit_slug(&resp, "NOTION_CREATE").unwrap(),
|
||||
"notion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_case_insensitive_match() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"{"items": [{"slug": "github_create_issue", "toolkit": {"slug": "github"}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_toolkit_slug(&resp, "GITHUB_CREATE_ISSUE").unwrap(),
|
||||
"github"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toolkit_slug_not_found() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"{"items": [{"slug": "OTHER_TOOL", "toolkit": {"slug": "other"}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let err = extract_toolkit_slug(&resp, "MISSING_TOOL").unwrap_err();
|
||||
assert!(err.contains("MISSING_TOOL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_active_account_v3_response() {
|
||||
// This tests the parsing logic — the actual API call is mocked by
|
||||
// testing the helper directly.
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"{"items": [
|
||||
{"id": "old-1", "status": "ACTIVE", "updated_at": "2024-01-01T00:00:00Z"},
|
||||
{"id": "new-2", "status": "ACTIVE", "updated_at": "2024-06-15T12:00:00Z"},
|
||||
{"id": "disabled-3", "status": "DISABLED", "updated_at": "2024-12-01T00:00:00Z"}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = unwrap_items(&resp).unwrap();
|
||||
let best = items
|
||||
.iter()
|
||||
.filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE"))
|
||||
.max_by_key(|a| {
|
||||
a.get("updated_at")
|
||||
.or_else(|| a.get("updatedAt"))
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
})
|
||||
.and_then(|a| a.get("id"))
|
||||
.and_then(|id| id.as_str());
|
||||
assert_eq!(best, Some("new-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_active_account_legacy_updated_at() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"[
|
||||
{"id": "a1", "status": "ACTIVE", "updatedAt": "2024-01-01"},
|
||||
{"id": "a2", "status": "ACTIVE", "updatedAt": "2024-06-01"}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = unwrap_items(&resp).unwrap();
|
||||
let best = items
|
||||
.iter()
|
||||
.filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE"))
|
||||
.max_by_key(|a| {
|
||||
a.get("updated_at")
|
||||
.or_else(|| a.get("updatedAt"))
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
})
|
||||
.and_then(|a| a.get("id"))
|
||||
.and_then(|id| id.as_str());
|
||||
assert_eq!(best, Some("a2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_active_account_no_active() {
|
||||
let resp: serde_json::Value = serde_json::from_str(
|
||||
r#"{"items": [{"id": "x", "status": "DISABLED", "updated_at": "2024-01-01"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = unwrap_items(&resp).unwrap();
|
||||
let best = items
|
||||
.iter()
|
||||
.filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE"))
|
||||
.max_by_key(|a| {
|
||||
a.get("updated_at")
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
});
|
||||
assert!(best.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user