use anyhow::Result;
use serde::Deserialize;
use url::Url;
use crate::drive::client::DriveClient;
use crate::drive::types::DrivePermission;
const MAX_PERMISSIONS: usize = 10_000;
const LIST_FIELDS: &str = "nextPageToken,permissions(id,type,role,emailAddress,domain)";
#[derive(Debug)]
pub struct PermissionsApi<'a> {
client: &'a DriveClient,
}
impl<'a> PermissionsApi<'a> {
#[must_use]
pub fn new(client: &'a DriveClient) -> Self {
Self { client }
}
pub async fn list_all(&self, file_or_folder_id: &str) -> Result<Vec<DrivePermission>> {
let mut acc: Vec<DrivePermission> = Vec::new();
let mut page_token: Option<String> = None;
loop {
let page = self
.list_page(file_or_folder_id, page_token.as_deref())
.await?;
acc.extend(page.permissions);
if acc.len() >= MAX_PERMISSIONS || page.next_page_token.is_none() {
break;
}
page_token = page.next_page_token;
}
acc.truncate(MAX_PERMISSIONS);
Ok(acc)
}
async fn list_page(
&self,
file_or_folder_id: &str,
page_token: Option<&str>,
) -> Result<PermissionListResponse> {
let url =
build_permissions_list_url(self.client.base_url(), file_or_folder_id, page_token)?;
self.client
.get_parsed(url.as_str(), "Failed to parse permissions.list response")
.await
}
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PermissionListResponse {
#[serde(default)]
permissions: Vec<DrivePermission>,
#[serde(default, rename = "nextPageToken")]
next_page_token: Option<String>,
}
fn build_permissions_list_url(
base_url: &str,
file_or_folder_id: &str,
page_token: Option<&str>,
) -> Result<Url> {
let mut url = DriveClient::api_url(
base_url,
&format!("/drive/v3/files/{file_or_folder_id}/permissions"),
)?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("fields", LIST_FIELDS);
pairs.append_pair("supportsAllDrives", "true");
if let Some(token) = page_token {
pairs.append_pair("pageToken", token);
}
}
Ok(url)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveScope};
use crate::utils::secret::Secret;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveScope::Metadata,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
#[tokio::test]
async fn list_all_returns_a_single_page_verbatim() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
.and(wiremock::matchers::query_param("fields", LIST_FIELDS))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"permissions": [
{"id": "p1", "type": "user", "role": "reader", "emailAddress": "alice@example.com"},
],
})),
)
.expect(1)
.mount(&server)
.await;
let perms = PermissionsApi::new(&client).list_all("f1").await.unwrap();
assert_eq!(perms.len(), 1);
assert_eq!(perms[0].email_address.as_deref(), Some("alice@example.com"));
}
#[tokio::test]
async fn list_all_follows_next_page_token_to_exhaustion() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
.and(wiremock::matchers::query_param_is_missing("pageToken"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"permissions": [
{"id": "p1", "type": "user", "role": "reader", "emailAddress": "alice@example.com"},
],
"nextPageToken": "page-2",
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
.and(wiremock::matchers::query_param("pageToken", "page-2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"permissions": [
{"id": "p2", "type": "user", "role": "reader", "emailAddress": "bob@example.com"},
],
})),
)
.expect(1)
.mount(&server)
.await;
let perms = PermissionsApi::new(&client).list_all("f1").await.unwrap();
assert_eq!(perms.len(), 2);
assert_eq!(perms[0].email_address.as_deref(), Some("alice@example.com"));
assert_eq!(perms[1].email_address.as_deref(), Some("bob@example.com"));
}
#[tokio::test]
async fn list_all_returns_empty_for_a_permission_less_response() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let perms = PermissionsApi::new(&client).list_all("f1").await.unwrap();
assert!(perms.is_empty());
}
#[tokio::test]
async fn list_all_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/drive/v3/files/missing/permissions",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let err = PermissionsApi::new(&client)
.list_all("missing")
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn list_all_works_for_a_folder_id_the_same_as_a_file_id() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/drive/v3/files/folder1/permissions",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"permissions": [
{"id": "p1", "type": "domain", "role": "reader", "domain": "example.com"},
],
})),
)
.mount(&server)
.await;
let perms = PermissionsApi::new(&client)
.list_all("folder1")
.await
.unwrap();
assert_eq!(perms[0].domain.as_deref(), Some("example.com"));
}
}