Files
codex/codex-rs/connectors/src/lib.rs
Matthew Zeng 2f3a2d7a86 Using cached connector directory for discoverable tools list (#21497)
## Summary

Startup tool construction currently depends on connector directory
metadata for `tool_suggest` discoverables. On a cold directory cache,
that can put slow connector-directory requests on the blocking path even
though the tools array only needs directory data for install
suggestions, not for the live connector MCP tools themselves.

This PR keeps the discoverables path off that cold network fetch:
- read connector directory metadata from cache only when building
discoverable tools
- persist connector directory metadata to
`~/.codex/cache/codex_app_directory/<hash>.json` and use it to hydrate
the in-memory cache on later runs before the normal refresh path updates
it
- use connector-directory-specific cache naming to distinguish this
metadata cache from the separate Codex Apps tools-spec cache

This reduces first-turn startup work without changing how live connector
MCP tools are sourced. Longer term, directory-backed install suggestions
should move to a search-based flow so they no longer need to be inlined
into the tools prompt at all.

## Testing

- `cargo test -p codex-connectors`
- `cargo test -p codex-chatgpt`
- `cargo test -p codex-core
request_plugin_install_is_available_without_search_tool_after_discovery_attempts`
- `cargo test -p codex-core
tool_suggest_uses_connector_id_fallback_when_directory_cache_is_empty`
2026-05-08 14:14:11 -07:00

810 lines
28 KiB
Rust

use std::collections::HashMap;
use std::future::Future;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::AppBranding;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppMetadata;
use serde::Deserialize;
use serde::Serialize;
pub mod accessible;
mod directory_cache;
pub mod filter;
pub mod merge;
pub mod metadata;
pub use directory_cache::ConnectorDirectoryCacheContext;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConnectorDirectoryCacheKey {
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
impl ConnectorDirectoryCacheKey {
pub fn new(
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
) -> Self {
Self {
chatgpt_base_url,
account_id,
chatgpt_user_id,
is_workspace_account,
}
}
}
#[derive(Clone)]
struct CachedConnectorDirectory {
key: ConnectorDirectoryCacheKey,
expires_at: Instant,
connectors: Vec<AppInfo>,
}
static CONNECTOR_DIRECTORY_CACHE: LazyLock<StdMutex<Option<CachedConnectorDirectory>>> =
LazyLock::new(|| StdMutex::new(None));
#[derive(Debug, Deserialize)]
pub struct DirectoryListResponse {
apps: Vec<DirectoryApp>,
#[serde(alias = "nextToken")]
next_token: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DirectoryApp {
id: String,
name: String,
description: Option<String>,
#[serde(alias = "appMetadata")]
app_metadata: Option<AppMetadata>,
branding: Option<AppBranding>,
labels: Option<HashMap<String, String>>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(alias = "logoUrlDark")]
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
visibility: Option<String>,
}
pub fn cached_directory_connectors(
cache_context: &ConnectorDirectoryCacheContext,
) -> Option<Vec<AppInfo>> {
if let Some(cached_connectors) = cached_directory_connectors_in_memory(&cache_context.cache_key)
{
return Some(cached_connectors);
}
let directory_cache::CachedConnectorDirectoryDiskLoad::Hit { connectors } =
directory_cache::load_cached_directory_connectors_from_disk(cache_context)
else {
return None;
};
write_cached_directory_connectors_in_memory(
cache_context.cache_key.clone(),
&connectors,
Duration::ZERO,
);
Some(connectors)
}
fn cached_directory_connectors_in_memory(
cache_key: &ConnectorDirectoryCacheKey,
) -> Option<Vec<AppInfo>> {
let cache_guard = CONNECTOR_DIRECTORY_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache_guard
.as_ref()
.filter(|cached| cached.key == *cache_key)
.map(|cached| cached.connectors.clone())
}
fn unexpired_directory_connectors_in_memory(
cache_key: &ConnectorDirectoryCacheKey,
) -> Option<Vec<AppInfo>> {
let cache_guard = CONNECTOR_DIRECTORY_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cached = cache_guard.as_ref()?;
if cached.key == *cache_key && Instant::now() < cached.expires_at {
return Some(cached.connectors.clone());
}
None
}
pub async fn list_all_connectors_with_options<F, Fut>(
cache_context: ConnectorDirectoryCacheContext,
is_workspace_account: bool,
force_refetch: bool,
mut fetch_page: F,
) -> anyhow::Result<Vec<AppInfo>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
if !force_refetch
&& let Some(cached_connectors) =
unexpired_directory_connectors_in_memory(&cache_context.cache_key)
{
return Ok(cached_connectors);
}
let mut apps = list_directory_connectors(&mut fetch_page).await?;
if is_workspace_account {
apps.extend(list_workspace_connectors(&mut fetch_page).await?);
}
let mut connectors = merge_directory_apps(apps)
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
write_cached_directory_connectors(&cache_context, &connectors);
Ok(connectors)
}
fn write_cached_directory_connectors(
cache_context: &ConnectorDirectoryCacheContext,
connectors: &[AppInfo],
) {
write_cached_directory_connectors_in_memory(
cache_context.cache_key.clone(),
connectors,
CONNECTORS_CACHE_TTL,
);
directory_cache::write_cached_directory_connectors_to_disk(cache_context, connectors);
}
fn write_cached_directory_connectors_in_memory(
cache_key: ConnectorDirectoryCacheKey,
connectors: &[AppInfo],
ttl: Duration,
) {
let mut cache_guard = CONNECTOR_DIRECTORY_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = Some(CachedConnectorDirectory {
key: cache_key,
expires_at: Instant::now() + ttl,
connectors: connectors.to_vec(),
});
}
async fn list_directory_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let mut apps = Vec::new();
let mut next_token: Option<String> = None;
loop {
let path = match next_token.as_deref() {
Some(token) => {
let encoded_token = urlencoding::encode(token);
format!("/connectors/directory/list?token={encoded_token}&external_logos=true")
}
None => "/connectors/directory/list?external_logos=true".to_string(),
};
let response = fetch_page(path).await?;
apps.extend(
response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app)),
);
next_token = response
.next_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
if next_token.is_none() {
break;
}
}
Ok(apps)
}
async fn list_workspace_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let response =
fetch_page("/connectors/directory/list_workspace?external_logos=true".to_string()).await;
match response {
Ok(response) => Ok(response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app))
.collect()),
Err(_) => Ok(Vec::new()),
}
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
if let Some(existing) = merged.get_mut(&app.id) {
merge_directory_app(existing, app);
} else {
merged.insert(app.id.clone(), app);
}
}
merged.into_values().collect()
}
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
let DirectoryApp {
id: _,
name,
description,
app_metadata,
branding,
labels,
logo_url,
logo_url_dark,
distribution_channel,
visibility: _,
} = incoming;
let incoming_name_is_empty = name.trim().is_empty();
if existing.name.trim().is_empty() && !incoming_name_is_empty {
existing.name = name;
}
let incoming_description_present = description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if incoming_description_present {
existing.description = description;
}
if existing.logo_url.is_none() && logo_url.is_some() {
existing.logo_url = logo_url;
}
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
existing.logo_url_dark = logo_url_dark;
}
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
existing_branding.category = incoming_branding.category;
}
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
existing_branding.developer = incoming_branding.developer;
}
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
existing_branding.website = incoming_branding.website;
}
if existing_branding.privacy_policy.is_none()
&& incoming_branding.privacy_policy.is_some()
{
existing_branding.privacy_policy = incoming_branding.privacy_policy;
}
if existing_branding.terms_of_service.is_none()
&& incoming_branding.terms_of_service.is_some()
{
existing_branding.terms_of_service = incoming_branding.terms_of_service;
}
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
existing_branding.is_discoverable_app = true;
}
} else {
existing.branding = Some(incoming_branding);
}
}
if let Some(incoming_app_metadata) = app_metadata {
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
existing_app_metadata.review = incoming_app_metadata.review;
}
if existing_app_metadata.categories.is_none()
&& incoming_app_metadata.categories.is_some()
{
existing_app_metadata.categories = incoming_app_metadata.categories;
}
if existing_app_metadata.sub_categories.is_none()
&& incoming_app_metadata.sub_categories.is_some()
{
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
}
if existing_app_metadata.seo_description.is_none()
&& incoming_app_metadata.seo_description.is_some()
{
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
}
if existing_app_metadata.screenshots.is_none()
&& incoming_app_metadata.screenshots.is_some()
{
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
}
if existing_app_metadata.developer.is_none()
&& incoming_app_metadata.developer.is_some()
{
existing_app_metadata.developer = incoming_app_metadata.developer;
}
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
existing_app_metadata.version = incoming_app_metadata.version;
}
if existing_app_metadata.version_id.is_none()
&& incoming_app_metadata.version_id.is_some()
{
existing_app_metadata.version_id = incoming_app_metadata.version_id;
}
if existing_app_metadata.version_notes.is_none()
&& incoming_app_metadata.version_notes.is_some()
{
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
}
if existing_app_metadata.first_party_type.is_none()
&& incoming_app_metadata.first_party_type.is_some()
{
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
}
if existing_app_metadata.first_party_requires_install.is_none()
&& incoming_app_metadata.first_party_requires_install.is_some()
{
existing_app_metadata.first_party_requires_install =
incoming_app_metadata.first_party_requires_install;
}
if existing_app_metadata
.show_in_composer_when_unlinked
.is_none()
&& incoming_app_metadata
.show_in_composer_when_unlinked
.is_some()
{
existing_app_metadata.show_in_composer_when_unlinked =
incoming_app_metadata.show_in_composer_when_unlinked;
}
} else {
existing.app_metadata = Some(incoming_app_metadata);
}
}
if existing.labels.is_none() && labels.is_some() {
existing.labels = labels;
}
}
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
matches!(app.visibility.as_deref(), Some("HIDDEN"))
}
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
AppInfo {
id: app.id,
name: app.name,
description: app.description,
logo_url: app.logo_url,
logo_url_dark: app.logo_url_dark,
distribution_channel: app.distribution_channel,
branding: app.branding,
app_metadata: app.app_metadata,
labels: app.labels,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}
}
fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
}
fn connector_name_slug(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
for character in name.chars() {
if character.is_ascii_alphanumeric() {
normalized.push(character.to_ascii_lowercase());
} else {
normalized.push('-');
}
}
let normalized = normalized.trim_matches('-');
if normalized.is_empty() {
"app".to_string()
} else {
normalized.to_string()
}
}
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
connector_id.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
static CONNECTOR_DIRECTORY_CACHE_TEST_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
fn cache_key(id: &str) -> ConnectorDirectoryCacheKey {
ConnectorDirectoryCacheKey::new(
"https://chatgpt.example".to_string(),
Some(format!("account-{id}")),
Some(format!("user-{id}")),
/*is_workspace_account*/ true,
)
}
fn cache_context(codex_home: &TempDir, id: &str) -> ConnectorDirectoryCacheContext {
ConnectorDirectoryCacheContext::new(codex_home.path().to_path_buf(), cache_key(id))
}
fn clear_directory_memory_cache() {
let mut cache_guard = CONNECTOR_DIRECTORY_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = None;
}
fn app(id: &str, name: &str) -> DirectoryApp {
DirectoryApp {
id: id.to_string(),
name: name.to_string(),
description: None,
app_metadata: None,
branding: None,
labels: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
visibility: None,
}
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to the shared connector cache for its full duration"
)]
async fn list_all_connectors_uses_shared_directory_cache() -> anyhow::Result<()> {
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
let codex_home = TempDir::new()?;
let cache_context = cache_context(&codex_home, "shared");
let first = list_all_connectors_with_options(
cache_context.clone(),
/*is_workspace_account*/ false,
/*force_refetch*/ false,
move |_path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
Ok(DirectoryListResponse {
apps: vec![app("alpha", "Alpha")],
next_token: None,
})
}
},
)
.await?;
let second = list_all_connectors_with_options(
cache_context,
/*is_workspace_account*/ false,
/*force_refetch*/ false,
move |_path| async move {
anyhow::bail!("cache should have been used");
},
)
.await?;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to the shared connector cache for its full duration"
)]
async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> {
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
let codex_home = TempDir::new()?;
let cache_context = cache_context(&codex_home, "merged");
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
let connectors = list_all_connectors_with_options(
cache_context,
/*is_workspace_account*/ true,
/*force_refetch*/ true,
move |path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
if path.starts_with("/connectors/directory/list_workspace") {
Ok(DirectoryListResponse {
apps: vec![
DirectoryApp {
description: Some("Merged description".to_string()),
branding: Some(AppBranding {
category: Some("calendar".to_string()),
developer: None,
website: None,
privacy_policy: None,
terms_of_service: None,
is_discoverable_app: true,
}),
..app("alpha", "")
},
DirectoryApp {
visibility: Some("HIDDEN".to_string()),
..app("hidden", "Hidden")
},
],
next_token: None,
})
} else {
Ok(DirectoryListResponse {
apps: vec![app("alpha", " Alpha "), app("beta", "Beta")],
next_token: None,
})
}
}
},
)
.await?;
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(connectors.len(), 2);
assert_eq!(connectors[0].id, "alpha");
assert_eq!(connectors[0].name, "Alpha");
assert_eq!(
connectors[0].description.as_deref(),
Some("Merged description")
);
assert_eq!(
connectors[0].install_url.as_deref(),
Some("https://chatgpt.com/apps/alpha/alpha")
);
assert_eq!(
connectors[0]
.branding
.as_ref()
.and_then(|branding| branding.category.as_deref()),
Some("calendar")
);
assert_eq!(connectors[1].id, "beta");
assert_eq!(connectors[1].name, "Beta");
Ok(())
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to the shared connector cache for its full duration"
)]
async fn cached_directory_connectors_reads_directory_disk_cache() -> anyhow::Result<()> {
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
let codex_home = TempDir::new()?;
let cache_context = cache_context(&codex_home, "disk");
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
let first = list_all_connectors_with_options(
cache_context.clone(),
/*is_workspace_account*/ false,
/*force_refetch*/ false,
move |_path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
Ok(DirectoryListResponse {
apps: vec![app("alpha", "Alpha")],
next_token: None,
})
}
},
)
.await?;
clear_directory_memory_cache();
let second = cached_directory_connectors(&cache_context).expect("disk cache should load");
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to the shared connector cache for its full duration"
)]
async fn list_all_connectors_refreshes_when_only_directory_disk_cache_exists()
-> anyhow::Result<()> {
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
let codex_home = TempDir::new()?;
let cache_context = cache_context(&codex_home, "disk-refresh");
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
list_all_connectors_with_options(
cache_context.clone(),
/*is_workspace_account*/ false,
/*force_refetch*/ false,
move |_path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
Ok(DirectoryListResponse {
apps: vec![app("alpha", "Alpha")],
next_token: None,
})
}
},
)
.await?;
clear_directory_memory_cache();
let mut cached_expected = directory_app_to_app_info(app("alpha", "Alpha"));
cached_expected.install_url = Some(connector_install_url(
&cached_expected.name,
&cached_expected.id,
));
assert_eq!(
cached_directory_connectors(&cache_context),
Some(vec![cached_expected])
);
let refreshed_calls = Arc::clone(&calls);
let refreshed = list_all_connectors_with_options(
cache_context,
/*is_workspace_account*/ false,
/*force_refetch*/ false,
move |_path| {
let call_counter = Arc::clone(&refreshed_calls);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
Ok(DirectoryListResponse {
apps: vec![app("beta", "Beta")],
next_token: None,
})
}
},
)
.await?;
let mut expected = directory_app_to_app_info(app("beta", "Beta"));
expected.install_url = Some(connector_install_url(&expected.name, &expected.id));
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(refreshed, vec![expected]);
Ok(())
}
#[tokio::test]
async fn cached_directory_connectors_drops_stale_disk_schema() -> anyhow::Result<()> {
let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await;
clear_directory_memory_cache();
let codex_home = TempDir::new()?;
let cache_context = cache_context(&codex_home, "stale-schema");
let cache_path = cache_context.cache_path();
std::fs::create_dir_all(cache_path.parent().expect("cache parent"))?;
std::fs::write(
&cache_path,
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 0,
"connectors": [],
}))?,
)?;
assert_eq!(cached_directory_connectors(&cache_context), None);
assert!(!cache_path.exists());
Ok(())
}
#[tokio::test]
async fn list_directory_connectors_omits_tier_for_all_pages() -> anyhow::Result<()> {
let requested_paths: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let paths = Arc::clone(&requested_paths);
let apps = list_directory_connectors(&mut move |path| {
let paths = Arc::clone(&paths);
async move {
paths
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(path.clone());
if path == "/connectors/directory/list?external_logos=true" {
Ok(DirectoryListResponse {
apps: vec![app("alpha", "Alpha")],
next_token: Some("page 2".to_string()),
})
} else {
assert_eq!(
path,
"/connectors/directory/list?token=page%202&external_logos=true"
);
Ok(DirectoryListResponse {
apps: vec![app("beta", "Beta")],
next_token: None,
})
}
}
})
.await?;
assert_eq!(
apps.iter().map(|app| app.id.as_str()).collect::<Vec<_>>(),
vec!["alpha", "beta"]
);
assert_eq!(
requested_paths
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_slice(),
&[
"/connectors/directory/list?external_logos=true".to_string(),
"/connectors/directory/list?token=page%202&external_logos=true".to_string(),
]
);
Ok(())
}
}