use std::collections::BTreeMap;
use std::sync::{Mutex, MutexGuard};
use crate::commands::safety::SafetyProfile;
use crate::core::auth::GoogleAuth;
use crate::core::http::HttpCore;
use crate::discovery::{self, Method, Service};
use crate::output::{OutputFormat, print_output};
use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand};
use serde_json::{Value, json};
#[derive(Subcommand, Debug)]
pub enum ApiCommands {
List(ApiListArgs),
Describe(ApiDescribeArgs),
Call(ApiCallArgs),
Refresh(ApiRefreshArgs),
}
#[derive(Args, Debug)]
pub struct ApiRefreshArgs {
#[arg(long)]
pub service: Option<String>,
#[arg(short, long, value_enum, default_value = "json")]
pub format: OutputFormat,
}
#[derive(Args, Debug)]
pub struct ApiListArgs {
#[arg(long)]
pub service: Option<String>,
#[arg(long)]
pub filter: Option<String>,
#[arg(long)]
pub grouped: bool,
#[arg(short, long, value_enum, default_value = "json")]
pub format: OutputFormat,
}
#[derive(Args, Debug)]
pub struct ApiDescribeArgs {
pub method: String,
#[arg(short, long, value_enum, default_value = "json")]
pub format: OutputFormat,
}
#[derive(Args, Debug)]
pub struct ApiCallArgs {
pub method: String,
#[arg(long, value_name = "JSON")]
pub params: Option<String>,
#[arg(long = "param", value_name = "KEY=VALUE")]
pub param: Vec<String>,
#[arg(long, value_name = "PATH")]
pub body_file: Option<String>,
#[arg(long, value_name = "VERB")]
pub method_override: Option<String>,
#[arg(long = "query", value_name = "KEY=VALUE")]
pub query: Vec<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(short, long, value_enum, default_value = "json")]
pub format: OutputFormat,
}
pub async fn handle_api_cmd(
auth: Option<&GoogleAuth>,
cmd: ApiCommands,
profile: &SafetyProfile,
) -> Result<()> {
match cmd {
ApiCommands::List(args) => handle_list(args)?,
ApiCommands::Describe(args) => handle_describe(args)?,
ApiCommands::Call(args) => {
handle_call(auth, args, profile).await?
}
ApiCommands::Refresh(args) => handle_refresh(args).await?,
}
Ok(())
}
async fn handle_refresh(args: ApiRefreshArgs) -> Result<()> {
let reports = crate::discovery::refresh(args.service.as_deref())
.await
.map_err(anyhow::Error::msg)?;
let refreshed = reports.iter().filter(|r| r.wrote_cache).count();
let skipped = reports.iter().filter(|r| r.skipped_older).count();
eprintln!("{refreshed} refreshed, {skipped} already current");
print_output(
&serde_json::json!({
"refreshed": refreshed,
"alreadyCurrent": skipped,
"services": reports.iter().map(|r| serde_json::json!({
"service": r.service,
"from": r.from,
"to": r.to,
"methods": r.methods,
"wroteCache": r.wrote_cache,
"skippedOlder": r.skipped_older,
})).collect::<Vec<_>>(),
"nextStep": "restart grr (each invocation is a fresh process) to use the refreshed index",
}),
args.format,
)?;
Ok(())
}
fn matches_filter(method: &Method, needle: &str) -> bool {
let needle = needle.to_ascii_lowercase();
method.id.to_ascii_lowercase().contains(&needle)
|| method.description.to_ascii_lowercase().contains(&needle)
}
fn handle_list(args: ApiListArgs) -> Result<()> {
let all = discovery::services();
let selected: Vec<(&&str, &Service)> = match &args.service {
Some(name) => {
let service = all.get_key_value(name.as_str()).ok_or_else(|| {
let known: Vec<&str> = all.keys().copied().collect();
anyhow::anyhow!("unknown service `{name}`; known: {}", known.join(", "))
})?;
vec![service]
}
None => all.iter().collect(),
};
let filtered: Vec<(&&str, &Service, Vec<&Method>)> = selected
.into_iter()
.map(|(name, service)| {
let methods: Vec<&Method> = match &args.filter {
Some(needle) => service
.methods
.iter()
.filter(|m| matches_filter(m, needle))
.collect(),
None => service.methods.iter().collect(),
};
(name, service, methods)
})
.filter(|(_, _, methods)| !methods.is_empty())
.collect();
if args.grouped {
let payload: Vec<Value> = filtered
.iter()
.map(|(_, service, methods)| {
json!({
"service": service.name,
"version": service.version,
"revision": service.revision,
"count": methods.len(),
"methods": methods.iter().map(|m| json!({
"id": format!("{}.{}", service.name, m.id),
"http": m.http_method,
})).collect::<Vec<_>>(),
})
})
.collect();
print_output(&Value::Array(payload), args.format)?;
return Ok(());
}
let total: usize = filtered.iter().map(|(_, _, m)| m.len()).sum();
let payload: Vec<Value> = filtered
.iter()
.flat_map(|(_, service, methods)| {
methods.iter().map(move |m| {
json!({
"id": format!("{}.{}", service.name, m.id),
"http": m.http_method,
"summary": first_sentence(&m.description),
})
})
})
.collect();
eprintln!("{total} methods");
print_output(&Value::Array(payload), args.format)?;
Ok(())
}
fn first_sentence(text: &str) -> String {
match text.find(". ") {
Some(idx) => text[..idx + 1].to_owned(),
None => text.chars().take(120).collect(),
}
}
fn handle_describe(args: ApiDescribeArgs) -> Result<()> {
let (service, method) = discovery::resolve(&args.method).map_err(anyhow::Error::msg)?;
print_output(
&json!({
"id": format!("{}.{}", service.name, method.id),
"service": service.name,
"apiVersion": service.version,
"discoveryRevision": service.revision,
"httpMethod": method.http_method,
"path": method.path,
"urlTemplate": format!(
"{}{}{}",
service.root_url.as_deref().unwrap_or_default(),
service.base(),
method.path
),
"description": method.description,
"scopes": method.scopes,
"leastPrivilegeScope": method.least_privilege_scope(),
"parameters": method.parameters.iter().map(|p| json!({
"name": p.name,
"type": p.kind,
"required": p.required,
"repeated": p.repeated,
"description": p.description,
"enum": p.enum_values,
})).collect::<Vec<_>>(),
"hint": format!(
"grr api call {}.{} --params '{{\"{}\": \"...\"}}'",
service.name, method.id,
method.parameters.first().map(|p| p.name.as_str()).unwrap_or("userId")
),
}),
args.format,
)?;
Ok(())
}
async fn handle_call(
auth: Option<&GoogleAuth>,
args: ApiCallArgs,
profile: &SafetyProfile,
) -> Result<()> {
let (service, method) = discovery::resolve(&args.method).map_err(|message| {
anyhow::anyhow!(
"{message}\n\nRun `grr api list {}` to see what is available.",
service_hint(&args.method)
)
})?;
if let Err(message) = profile.check(&service.name, method) {
bail!("{message}");
}
let params = parse_params(args.params.as_deref(), &args.param)?;
let payload = call_method(
auth,
service,
method,
params,
CallOptions {
body_file: args.body_file,
verb_override: args.method_override,
query_extras: args.query,
dry_run: args.dry_run,
},
)
.await?;
print_output(&payload, args.format)?;
Ok(())
}
#[derive(Debug, Default)]
pub(crate) struct CallOptions {
pub body_file: Option<String>,
pub verb_override: Option<String>,
pub query_extras: Vec<String>,
pub dry_run: bool,
}
#[derive(Debug)]
pub(crate) struct RequestPlan {
pub verb: String,
pub url: String,
pub body: Option<Value>,
}
pub(crate) fn plan_request(
service: &Service,
method: &Method,
params: &serde_json::Map<String, Value>,
body_raw: Option<&str>,
verb_override: Option<&str>,
query_extras: &[String],
) -> Result<RequestPlan> {
for required in &method.required {
if !params.contains_key(required) {
let known: Vec<String> = method
.parameters
.iter()
.filter(|p| p.required)
.map(|p| format!("{} ({})", p.name, p.kind))
.collect();
bail!(
"missing required parameter `{required}` for {}.{}\nRequired: {}",
service.name,
method.id,
if known.is_empty() {
"none".to_owned()
} else {
known.join(", ")
}
);
}
}
let verb = verb_override
.unwrap_or(&method.http_method)
.to_ascii_uppercase();
let path = discovery::build_path(service, method, params).map_err(anyhow::Error::msg)?;
let mut query = discovery::build_query(method, params);
for extra in query_extras {
let Some((k, v)) = extra.split_once('=') else {
bail!("--query expects KEY=VALUE, got `{extra}`");
};
if !query.is_empty() {
query.push('&');
}
query.push_str(&urlencoding::encode(k));
query.push('=');
query.push_str(&urlencoding::encode(v));
}
let url = format!(
"{}{}{}",
service.root_url.as_deref().unwrap_or_default(),
path,
query
);
let body: Option<Value> = match body_raw {
Some(raw) => {
Some(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_owned())))
}
None => {
let placeholders = method.path_placeholders();
let is_write = matches!(verb.as_str(), "POST" | "PATCH" | "PUT");
if !is_write {
None
} else {
let mut rest = serde_json::Map::new();
for (key, value) in params {
if placeholders.contains(&key.as_str()) {
continue;
}
if let Some(param) = method.parameter(key)
&& param.location.as_deref() == Some("path")
{
continue;
}
rest.insert(key.clone(), value.clone());
}
(!rest.is_empty()).then_some(Value::Object(rest))
}
}
};
Ok(RequestPlan { verb, url, body })
}
pub(crate) fn dry_run_payload(method: &Method, plan: &RequestPlan) -> Value {
json!({
"method": plan.verb,
"url": plan.url,
"body": plan.body,
"scopesRequested": method.scopes,
"leastPrivilegeScope": method.least_privilege_scope(),
"dryRun": true,
})
}
async fn read_body_source(source: &str) -> Result<String> {
if source == "-" {
let mut buf = String::new();
tokio::io::AsyncReadExt::read_to_string(&mut tokio::io::stdin(), &mut buf)
.await
.with_context(|| "reading body from stdin")?;
Ok(buf)
} else {
tokio::fs::read_to_string(source)
.await
.with_context(|| format!("reading body from {source}"))
}
}
static CORES: Mutex<BTreeMap<String, HttpCore>> = Mutex::new(BTreeMap::new());
const PROBE_PATH: &str = "";
async fn engine_for(auth: &GoogleAuth, base: &url::Url) -> Result<HttpCore> {
let key = base.as_str().to_owned();
if let Some(engine) = cores().get(&key) {
return Ok(engine.clone());
}
match HttpCore::connect(auth.clone(), base, PROBE_PATH).await {
Ok(engine) => {
cores().insert(key, engine.clone());
Ok(engine)
}
Err(error) => {
cores().remove(&key);
Err(error.into())
}
}
}
fn cores() -> MutexGuard<'static, BTreeMap<String, HttpCore>> {
CORES
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) async fn call_method(
auth: Option<&GoogleAuth>,
service: &Service,
method: &Method,
params: serde_json::Map<String, Value>,
options: CallOptions,
) -> Result<Value> {
let body_raw = match options.body_file.as_deref() {
Some(source) => Some(read_body_source(source).await?),
None => None,
};
let plan = plan_request(
service,
method,
¶ms,
body_raw.as_deref(),
options.verb_override.as_deref(),
&options.query_extras,
)?;
if options.dry_run {
return Ok(dry_run_payload(method, &plan));
}
let auth = auth.ok_or_else(|| anyhow::anyhow!("{}", crate::core::config::NO_CLIENT_HELP))?;
let parsed_url = url::Url::parse(&plan.url).with_context(|| {
format!(
"`{}` is not a valid URL (Discovery path + parameters)",
plan.url
)
})?;
let engine = engine_for(auth, &parsed_url.join("/")?).await?;
let mut request = engine.http_client().request(
reqwest::Method::from_bytes(plan.verb.as_bytes()).unwrap_or(reqwest::Method::GET),
parsed_url,
);
if let Some(payload) = &plan.body {
request = request.json(payload);
}
let response = engine.execute(request).await?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()));
if !status.is_success() {
bail!(
"{}",
api_failure_message(&plan, status.as_u16(), &parsed, &text, method)
);
}
Ok(parsed)
}
pub(crate) fn api_failure_message(
plan: &RequestPlan,
status: u16,
parsed: &Value,
text: &str,
method: &Method,
) -> String {
if status == 403
&& let Some(scope) = method.least_privilege_scope()
{
let detail = parsed["error"]["message"].as_str().unwrap_or("");
let google_said = if detail.is_empty() {
String::new()
} else {
format!("\nGoogle said: {detail}")
};
return format!(
"{} {} -> HTTP 403: this method needs the `{scope}` scope.\n\
This build requests every scope the embedded index names, so the grant is what \
to check: a token that predates the current set (re-run `grr auth login`), a \
scope the project's consent screen never declared (Google Auth Platform → Data \
access — undeclared scopes are dropped silently at consent), or an API that is \
disabled on the Cloud project.{google_said}",
plan.verb, plan.url
);
}
format!(
"{} {} -> HTTP {}\n{}",
plan.verb,
plan.url,
status,
serde_json::to_string_pretty(parsed).unwrap_or_else(|_| text.to_owned())
)
}
fn service_hint(method_id: &str) -> &str {
method_id.split('.').next().unwrap_or("gmail")
}
pub(crate) fn parse_params(
json_blob: Option<&str>,
pairs: &[String],
) -> Result<serde_json::Map<String, Value>> {
let mut map = serde_json::Map::new();
if let Some(blob) = json_blob {
let parsed: Value = serde_json::from_str(blob)
.with_context(|| "--params must be a JSON object, e.g. '{\"userId\":\"me\"}'")?;
let Value::Object(object) = parsed else {
bail!("--params must be a JSON object, got {}", type_name(&parsed));
};
map.extend(object);
}
for pair in pairs {
let Some((key, value)) = pair.split_once('=') else {
bail!("--param expects KEY=VALUE, got `{pair}`");
};
let coerced = serde_json::from_str::<Value>(value)
.unwrap_or_else(|_| Value::String(value.to_owned()));
map.insert(key.to_owned(), coerced);
}
Ok(map)
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_params_are_merged() {
let params = parse_params(Some(r#"{"userId":"me","q":"is:unread"}"#), &[]).unwrap();
assert_eq!(params.get("userId").unwrap(), "me");
assert_eq!(params.get("q").unwrap(), "is:unread");
}
#[test]
fn param_flags_win_over_json_params() {
let params =
parse_params(Some(r#"{"userId":"me"}"#), &["userId=someone-else".into()]).unwrap();
assert_eq!(params.get("userId").unwrap(), "someone-else");
}
#[test]
fn param_values_are_coerced_to_json_types() {
let params =
parse_params(None, &["a=10".into(), "b=true".into(), "c=text".into()]).unwrap();
assert_eq!(params.get("a").unwrap(), 10);
assert_eq!(params.get("b").unwrap(), true);
assert_eq!(params.get("c").unwrap(), "text");
}
#[test]
fn values_containing_equals_are_preserved() {
let params = parse_params(None, &["q=name=a.pdf".into()]).unwrap();
assert_eq!(params.get("q").unwrap(), "name=a.pdf");
}
#[test]
fn malformed_input_is_rejected_with_guidance() {
assert!(parse_params(Some("not json"), &[]).is_err());
assert!(parse_params(Some("[1,2]"), &[]).is_err());
assert!(parse_params(None, &["novalue".into()]).is_err());
}
fn plan(id: &str, params: &[(&str, Value)]) -> (super::RequestPlan, &'static Method) {
let (service, method) = crate::discovery::resolve(id).unwrap();
let map: serde_json::Map<String, Value> = params
.iter()
.map(|(k, v)| ((*k).to_owned(), v.clone()))
.collect();
(
super::plan_request(service, method, &map, None, None, &[]).unwrap(),
method,
)
}
#[test]
fn plan_builds_the_url_from_discovery() {
let (plan, method) = plan(
"gmail.users.messages.list",
&[("userId", json!("me")), ("q", json!("is:unread"))],
);
assert_eq!(plan.verb, "GET");
assert_eq!(
plan.url,
"https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread"
);
assert_eq!(plan.body, None);
assert_eq!(method.id, "users.messages.list");
}
#[test]
fn plan_rejects_a_missing_required_parameter_by_name() {
let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
let err = super::plan_request(service, method, &serde_json::Map::new(), None, None, &[])
.unwrap_err()
.to_string();
assert!(err.contains("missing required parameter `userId`"), "{err}");
}
#[test]
fn plan_appends_query_extras_after_discovery_pairs() {
let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
let mut params = serde_json::Map::new();
params.insert("userId".into(), json!("me"));
params.insert("q".into(), json!("is:unread"));
let plan = super::plan_request(
service,
method,
¶ms,
None,
None,
&["alt=json".to_owned()],
)
.unwrap();
assert!(plan.url.ends_with("q=is%3Aunread&alt=json"), "{}", plan.url);
}
#[test]
fn plan_rejects_malformed_query_extras() {
let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
let mut params = serde_json::Map::new();
params.insert("userId".into(), json!("me"));
let err = super::plan_request(
service,
method,
¶ms,
None,
None,
&["no-equals-sign".to_owned()],
)
.unwrap_err()
.to_string();
assert!(err.contains("KEY=VALUE"), "{err}");
}
#[test]
fn plan_uppercases_a_verb_override() {
let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
let mut params = serde_json::Map::new();
params.insert("userId".into(), json!("me"));
let plan = super::plan_request(service, method, ¶ms, None, Some("post"), &[]).unwrap();
assert_eq!(plan.verb, "POST");
}
#[test]
fn plan_synthesizes_a_body_for_write_verbs_only() {
let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
let mut params = serde_json::Map::new();
params.insert("name".into(), json!("quarterly.pdf"));
let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
assert_eq!(plan.body, Some(json!({"name": "quarterly.pdf"})));
let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
let mut params = serde_json::Map::new();
params.insert("userId".into(), json!("me"));
params.insert("q".into(), json!("is:unread"));
let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
assert_eq!(plan.body, None);
assert!(plan.url.contains("q=is%3Aunread"));
}
#[test]
fn plan_parses_json_bodies_and_wraps_the_rest() {
let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
let plan = super::plan_request(
service,
method,
&serde_json::Map::new(),
Some(r#"{"name":"uploaded.pdf"}"#),
None,
&[],
)
.unwrap();
assert_eq!(plan.body, Some(json!({"name": "uploaded.pdf"})));
let plan = super::plan_request(
service,
method,
&serde_json::Map::new(),
Some("not json at all"),
None,
&[],
)
.unwrap();
assert_eq!(plan.body, Some(json!("not json at all")));
}
#[tokio::test]
async fn read_body_source_reads_a_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("body.json");
tokio::fs::write(&path, r#"{"a":1}"#).await.unwrap();
let raw = super::read_body_source(path.to_str().unwrap())
.await
.unwrap();
assert_eq!(raw, r#"{"a":1}"#);
}
#[test]
fn dry_run_payload_is_a_pure_function_of_the_plan() {
let (plan, method) = plan("gmail.users.messages.list", &[("userId", json!("me"))]);
let payload = super::dry_run_payload(method, &plan);
assert_eq!(payload["method"], "GET");
assert_eq!(
payload["url"],
"https://gmail.googleapis.com/gmail/v1/users/me/messages"
);
assert_eq!(payload["dryRun"], true);
assert!(
payload["leastPrivilegeScope"]
.as_str()
.unwrap()
.ends_with("gmail.readonly")
);
}
fn a_get_plan() -> super::RequestPlan {
super::RequestPlan {
verb: "GET".into(),
url: "https://gmail.googleapis.com/gmail/v1/users/me/messages".into(),
body: None,
}
}
#[test]
fn a_403_names_the_missing_scope_and_keeps_googles_message() {
let (_, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
let parsed = json!({
"error": {"code": 403, "message": "Request had insufficient authentication scopes."}
});
let message = super::api_failure_message(&a_get_plan(), 403, &parsed, "", method);
assert!(message.contains("HTTP 403"), "{message}");
assert!(
message.contains("gmail.readonly"),
"the method's least-privilege scope must be named: {message}"
);
assert!(
message.contains("re-run `grr auth login`"),
"the remediation must be named: {message}"
);
assert!(
message.contains("insufficient authentication scopes"),
"Google's own message must be quoted, not replaced: {message}"
);
}
#[test]
fn other_statuses_keep_the_verb_url_body_shape() {
let (_, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
let parsed = json!({"error": {"code": 404, "message": "Requested entity was not found."}});
let message = super::api_failure_message(&a_get_plan(), 404, &parsed, "", method);
assert!(
message.contains(
"GET https://gmail.googleapis.com/gmail/v1/users/me/messages -> HTTP 404"
),
"{message}"
);
assert!(
message.contains("Requested entity was not found."),
"{message}"
);
}
async fn test_auth() -> GoogleAuth {
let config = crate::core::config::OAuthConfig {
client_id: "test-id.apps.googleusercontent.com".into(),
client_secret: None,
..Default::default()
};
let storage = crate::core::auth::TokenStorage {
access_token: "test-token".into(),
refresh_token: None,
expires_at: u64::MAX,
token_type: "Bearer".into(),
scope: String::new(),
};
GoogleAuth::with_token(config, storage).await.unwrap()
}
#[tokio::test]
async fn handle_call_refuses_a_write_under_readonly() {
let auth = test_auth().await;
let args = ApiCallArgs {
method: "gmail.users.messages.send".into(),
params: None,
param: vec![],
body_file: None,
method_override: None,
query: vec![],
dry_run: true,
format: OutputFormat::Json,
};
let err = handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
.await
.unwrap_err()
.to_string();
assert!(err.contains("POST"), "{err}");
assert!(err.contains("--readonly"), "{err}");
assert!(err.contains("Rerun without --readonly"), "{err}");
}
#[tokio::test]
async fn handle_call_refuses_a_denied_service_and_a_denied_verb() {
let auth = test_auth().await;
let args = ApiCallArgs {
method: "gmail.users.messages.list".into(),
params: None,
param: vec![],
body_file: None,
method_override: None,
query: vec![],
dry_run: true,
format: OutputFormat::Json,
};
let profile = SafetyProfile::new(false, vec!["gmail".to_owned()], vec![]);
let err = handle_call(Some(&auth), args, &profile)
.await
.unwrap_err()
.to_string();
assert!(err.contains("--deny-service gmail"), "{err}");
let args = ApiCallArgs {
method: "gmail.users.messages.list".into(),
params: None,
param: vec![],
body_file: None,
method_override: None,
query: vec![],
dry_run: true,
format: OutputFormat::Json,
};
let profile = SafetyProfile::new(false, vec![], vec!["get".to_owned()]);
let err = handle_call(Some(&auth), args, &profile)
.await
.unwrap_err()
.to_string();
assert!(err.contains("--deny-verb GET"), "{err}");
}
#[tokio::test]
async fn handle_call_runs_a_read_under_readonly() {
let auth = test_auth().await;
let args = ApiCallArgs {
method: "gmail.users.messages.list".into(),
params: None,
param: vec!["userId=me".into()],
body_file: None,
method_override: None,
query: vec![],
dry_run: true,
format: OutputFormat::Json,
};
assert!(
handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
.await
.is_ok()
);
}
}