1use crate::commands::safety::SafetyProfile;
28use crate::core::auth::GoogleAuth;
29use crate::discovery::{self, Method, Service};
30use crate::output::{OutputFormat, print_output};
31use anyhow::{Context, Result, bail};
32use clap::{Args, Subcommand};
33use serde_json::{Value, json};
34
35#[derive(Subcommand, Debug)]
36pub enum ApiCommands {
37 List(ApiListArgs),
39 Describe(ApiDescribeArgs),
41 Call(ApiCallArgs),
43 Refresh(ApiRefreshArgs),
46}
47
48#[derive(Args, Debug)]
49pub struct ApiRefreshArgs {
50 #[arg(long)]
54 pub service: Option<String>,
55
56 #[arg(short, long, value_enum, default_value = "json")]
58 pub format: OutputFormat,
59}
60
61#[derive(Args, Debug)]
62pub struct ApiListArgs {
63 #[arg(long)]
67 pub service: Option<String>,
68
69 #[arg(long)]
71 pub filter: Option<String>,
72
73 #[arg(long)]
75 pub grouped: bool,
76
77 #[arg(short, long, value_enum, default_value = "json")]
79 pub format: OutputFormat,
80}
81
82#[derive(Args, Debug)]
83pub struct ApiDescribeArgs {
84 pub method: String,
86
87 #[arg(short, long, value_enum, default_value = "json")]
89 pub format: OutputFormat,
90}
91
92#[derive(Args, Debug)]
93pub struct ApiCallArgs {
94 pub method: String,
96
97 #[arg(long, value_name = "JSON")]
101 pub params: Option<String>,
102
103 #[arg(long = "param", value_name = "KEY=VALUE")]
106 pub param: Vec<String>,
107
108 #[arg(long, value_name = "PATH")]
111 pub body_file: Option<String>,
112
113 #[arg(long, value_name = "VERB")]
115 pub method_override: Option<String>,
116
117 #[arg(long = "query", value_name = "KEY=VALUE")]
120 pub query: Vec<String>,
121
122 #[arg(long)]
124 pub dry_run: bool,
125
126 #[arg(short, long, value_enum, default_value = "json")]
133 pub format: OutputFormat,
134}
135
136pub async fn handle_api_cmd(
137 auth: Option<&GoogleAuth>,
138 cmd: ApiCommands,
139 profile: &SafetyProfile,
140) -> Result<()> {
141 match cmd {
142 ApiCommands::List(args) => handle_list(args)?,
144 ApiCommands::Describe(args) => handle_describe(args)?,
145 ApiCommands::Call(args) => {
146 handle_call(auth, args, profile).await?
151 }
152 ApiCommands::Refresh(args) => handle_refresh(args).await?,
154 }
155 Ok(())
156}
157
158async fn handle_refresh(args: ApiRefreshArgs) -> Result<()> {
159 let reports = crate::discovery::refresh(args.service.as_deref())
160 .await
161 .map_err(anyhow::Error::msg)?;
162
163 let refreshed = reports.iter().filter(|r| r.wrote_cache).count();
164 let skipped = reports.iter().filter(|r| r.skipped_older).count();
165 eprintln!("{refreshed} refreshed, {skipped} already current");
167 print_output(
168 &serde_json::json!({
169 "refreshed": refreshed,
170 "alreadyCurrent": skipped,
171 "services": reports.iter().map(|r| serde_json::json!({
172 "service": r.service,
173 "from": r.from,
174 "to": r.to,
175 "methods": r.methods,
176 "wroteCache": r.wrote_cache,
177 "skippedOlder": r.skipped_older,
178 })).collect::<Vec<_>>(),
179 "nextStep": "restart grr (each invocation is a fresh process) to use the refreshed index",
180 }),
181 args.format,
182 )?;
183 Ok(())
184}
185
186fn matches_filter(method: &Method, needle: &str) -> bool {
187 let needle = needle.to_ascii_lowercase();
188 method.id.to_ascii_lowercase().contains(&needle)
189 || method.description.to_ascii_lowercase().contains(&needle)
190}
191
192fn handle_list(args: ApiListArgs) -> Result<()> {
193 let all = discovery::services();
194 let selected: Vec<(&&str, &Service)> = match &args.service {
195 Some(name) => {
196 let service = all.get_key_value(name.as_str()).ok_or_else(|| {
197 let known: Vec<&str> = all.keys().copied().collect();
198 anyhow::anyhow!("unknown service `{name}`; known: {}", known.join(", "))
199 })?;
200 vec![service]
201 }
202 None => all.iter().collect(),
203 };
204
205 let filtered: Vec<(&&str, &Service, Vec<&Method>)> = selected
206 .into_iter()
207 .map(|(name, service)| {
208 let methods: Vec<&Method> = match &args.filter {
209 Some(needle) => service
210 .methods
211 .iter()
212 .filter(|m| matches_filter(m, needle))
213 .collect(),
214 None => service.methods.iter().collect(),
215 };
216 (name, service, methods)
217 })
218 .filter(|(_, _, methods)| !methods.is_empty())
219 .collect();
220
221 if args.grouped {
222 let payload: Vec<Value> = filtered
223 .iter()
224 .map(|(_, service, methods)| {
225 json!({
226 "service": service.name,
227 "version": service.version,
228 "revision": service.revision,
229 "count": methods.len(),
230 "methods": methods.iter().map(|m| json!({
231 "id": format!("{}.{}", service.name, m.id),
232 "http": m.http_method,
233 })).collect::<Vec<_>>(),
234 })
235 })
236 .collect();
237 print_output(&Value::Array(payload), args.format)?;
238 return Ok(());
239 }
240
241 let total: usize = filtered.iter().map(|(_, _, m)| m.len()).sum();
242 let payload: Vec<Value> = filtered
243 .iter()
244 .flat_map(|(_, service, methods)| {
245 methods.iter().map(move |m| {
246 json!({
247 "id": format!("{}.{}", service.name, m.id),
248 "http": m.http_method,
249 "summary": first_sentence(&m.description),
250 })
251 })
252 })
253 .collect();
254
255 eprintln!("{total} methods");
257 print_output(&Value::Array(payload), args.format)?;
258 Ok(())
259}
260
261fn first_sentence(text: &str) -> String {
262 match text.find(". ") {
263 Some(idx) => text[..idx + 1].to_owned(),
264 None => text.chars().take(120).collect(),
265 }
266}
267
268fn handle_describe(args: ApiDescribeArgs) -> Result<()> {
269 let (service, method) = discovery::resolve(&args.method).map_err(anyhow::Error::msg)?;
270 print_output(
271 &json!({
272 "id": format!("{}.{}", service.name, method.id),
273 "service": service.name,
274 "apiVersion": service.version,
275 "discoveryRevision": service.revision,
276 "httpMethod": method.http_method,
277 "path": method.path,
278 "urlTemplate": format!(
281 "{}{}{}",
282 service.root_url.as_deref().unwrap_or_default(),
283 service.base(),
284 method.path
285 ),
286 "description": method.description,
287 "scopes": method.scopes,
288 "leastPrivilegeScope": method.least_privilege_scope(),
289 "parameters": method.parameters.iter().map(|p| json!({
290 "name": p.name,
291 "type": p.kind,
292 "required": p.required,
293 "repeated": p.repeated,
294 "description": p.description,
295 "enum": p.enum_values,
296 })).collect::<Vec<_>>(),
297 "hint": format!(
298 "grr api call {}.{} --params '{{\"{}\": \"...\"}}'",
299 service.name, method.id,
300 method.parameters.first().map(|p| p.name.as_str()).unwrap_or("userId")
301 ),
302 }),
303 args.format,
304 )?;
305 Ok(())
306}
307
308async fn handle_call(
309 auth: Option<&GoogleAuth>,
310 args: ApiCallArgs,
311 profile: &SafetyProfile,
312) -> Result<()> {
313 let (service, method) = discovery::resolve(&args.method).map_err(|message| {
314 anyhow::anyhow!(
315 "{message}\n\nRun `grr api list {}` to see what is available.",
316 service_hint(&args.method)
317 )
318 })?;
319
320 if let Err(message) = profile.check(&service.name, method) {
325 bail!("{message}");
326 }
327
328 let params = parse_params(args.params.as_deref(), &args.param)?;
329 let payload = call_method(
330 auth,
331 service,
332 method,
333 params,
334 CallOptions {
335 body_file: args.body_file,
336 verb_override: args.method_override,
337 query_extras: args.query,
338 dry_run: args.dry_run,
339 },
340 )
341 .await?;
342 print_output(&payload, args.format)?;
343 Ok(())
344}
345
346#[derive(Debug, Default)]
350pub(crate) struct CallOptions {
351 pub body_file: Option<String>,
354 pub verb_override: Option<String>,
357 pub query_extras: Vec<String>,
359 pub dry_run: bool,
361}
362
363#[derive(Debug)]
367pub(crate) struct RequestPlan {
368 pub verb: String,
369 pub url: String,
370 pub body: Option<Value>,
371}
372
373pub(crate) fn plan_request(
381 service: &Service,
382 method: &Method,
383 params: &serde_json::Map<String, Value>,
384 body_raw: Option<&str>,
385 verb_override: Option<&str>,
386 query_extras: &[String],
387) -> Result<RequestPlan> {
388 for required in &method.required {
390 if !params.contains_key(required) {
391 let known: Vec<String> = method
392 .parameters
393 .iter()
394 .filter(|p| p.required)
395 .map(|p| format!("{} ({})", p.name, p.kind))
396 .collect();
397 bail!(
398 "missing required parameter `{required}` for {}.{}\nRequired: {}",
399 service.name,
400 method.id,
401 if known.is_empty() {
402 "none".to_owned()
403 } else {
404 known.join(", ")
405 }
406 );
407 }
408 }
409
410 let verb = verb_override
411 .unwrap_or(&method.http_method)
412 .to_ascii_uppercase();
413 let path = discovery::build_path(service, method, params).map_err(anyhow::Error::msg)?;
414 let mut query = discovery::build_query(method, params);
415 for extra in query_extras {
416 let Some((k, v)) = extra.split_once('=') else {
417 bail!("--query expects KEY=VALUE, got `{extra}`");
418 };
419 if !query.is_empty() {
420 query.push('&');
421 }
422 query.push_str(&urlencoding::encode(k));
423 query.push('=');
424 query.push_str(&urlencoding::encode(v));
425 }
426 let url = format!(
427 "{}{}{}",
428 service.root_url.as_deref().unwrap_or_default(),
429 path,
430 query
431 );
432
433 let body: Option<Value> = match body_raw {
438 Some(raw) => {
439 Some(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_owned())))
440 }
441 None => {
442 let placeholders = method.path_placeholders();
443 let is_write = matches!(verb.as_str(), "POST" | "PATCH" | "PUT");
444 if !is_write {
445 None
446 } else {
447 let mut rest = serde_json::Map::new();
448 for (key, value) in params {
449 if placeholders.contains(&key.as_str()) {
450 continue;
451 }
452 if let Some(param) = method.parameter(key)
453 && param.location.as_deref() == Some("path")
454 {
455 continue;
456 }
457 rest.insert(key.clone(), value.clone());
458 }
459 (!rest.is_empty()).then_some(Value::Object(rest))
460 }
461 }
462 };
463
464 Ok(RequestPlan { verb, url, body })
465}
466
467pub(crate) fn dry_run_payload(method: &Method, plan: &RequestPlan) -> Value {
470 json!({
471 "method": plan.verb,
472 "url": plan.url,
473 "body": plan.body,
474 "scopesRequested": method.scopes,
475 "leastPrivilegeScope": method.least_privilege_scope(),
476 "dryRun": true,
477 })
478}
479
480async fn read_body_source(source: &str) -> Result<String> {
489 if source == "-" {
490 let mut buf = String::new();
491 tokio::io::AsyncReadExt::read_to_string(&mut tokio::io::stdin(), &mut buf)
492 .await
493 .with_context(|| "reading body from stdin")?;
494 Ok(buf)
495 } else {
496 tokio::fs::read_to_string(source)
497 .await
498 .with_context(|| format!("reading body from {source}"))
499 }
500}
501
502pub(crate) async fn call_method(
508 auth: Option<&GoogleAuth>,
509 service: &Service,
510 method: &Method,
511 params: serde_json::Map<String, Value>,
512 options: CallOptions,
513) -> Result<Value> {
514 let body_raw = match options.body_file.as_deref() {
515 Some(source) => Some(read_body_source(source).await?),
516 None => None,
517 };
518 let plan = plan_request(
519 service,
520 method,
521 ¶ms,
522 body_raw.as_deref(),
523 options.verb_override.as_deref(),
524 &options.query_extras,
525 )?;
526
527 if options.dry_run {
528 return Ok(dry_run_payload(method, &plan));
532 }
533
534 let auth = auth.ok_or_else(|| anyhow::anyhow!("{}", crate::core::config::NO_CLIENT_HELP))?;
536
537 let token = auth.get_access_token().await?;
538
539 let parsed_url = url::Url::parse(&plan.url).with_context(|| {
540 format!(
541 "`{}` is not a valid URL (Discovery path + parameters)",
542 plan.url
543 )
544 })?;
545
546 let mut request = auth.http().request(
547 reqwest::Method::from_bytes(plan.verb.as_bytes()).unwrap_or(reqwest::Method::GET),
548 parsed_url,
549 );
550 if let Some(payload) = &plan.body {
551 request = request.json(payload);
552 }
553 let response = request
554 .bearer_auth(token)
555 .send()
556 .await
557 .map_err(|e| anyhow::anyhow!("{} {} failed: {e}", plan.verb, plan.url))?;
558
559 let status = response.status();
560 let text = response.text().await.unwrap_or_default();
561 let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()));
562
563 if !status.is_success() {
564 if status.as_u16() == 403
570 && let Some(scope) = method.least_privilege_scope()
571 {
572 let detail = parsed["error"]["message"].as_str().unwrap_or("");
573 let google_said = if detail.is_empty() {
574 String::new()
575 } else {
576 format!("\nGoogle said: {detail}")
577 };
578 bail!(
579 "{} {} -> HTTP 403: this method needs the `{scope}` scope.\n\
580 This build requests every scope the embedded index names, so the grant is what \
581 to check: a token that predates the current set (re-run `grr auth login`), a \
582 scope the project's consent screen never declared (Google Auth Platform → Data \
583 access — undeclared scopes are dropped silently at consent), or an API that is \
584 disabled on the Cloud project.{google_said}",
585 plan.verb,
586 plan.url
587 );
588 }
589 bail!(
590 "{} {} -> HTTP {}\n{}",
591 plan.verb,
592 plan.url,
593 status.as_u16(),
594 serde_json::to_string_pretty(&parsed).unwrap_or(text)
595 );
596 }
597
598 Ok(parsed)
599}
600
601fn service_hint(method_id: &str) -> &str {
602 method_id.split('.').next().unwrap_or("gmail")
603}
604
605pub(crate) fn parse_params(
610 json_blob: Option<&str>,
611 pairs: &[String],
612) -> Result<serde_json::Map<String, Value>> {
613 let mut map = serde_json::Map::new();
614
615 if let Some(blob) = json_blob {
616 let parsed: Value = serde_json::from_str(blob)
617 .with_context(|| "--params must be a JSON object, e.g. '{\"userId\":\"me\"}'")?;
618 let Value::Object(object) = parsed else {
619 bail!("--params must be a JSON object, got {}", type_name(&parsed));
620 };
621 map.extend(object);
622 }
623
624 for pair in pairs {
625 let Some((key, value)) = pair.split_once('=') else {
626 bail!("--param expects KEY=VALUE, got `{pair}`");
627 };
628 let coerced = serde_json::from_str::<Value>(value)
632 .unwrap_or_else(|_| Value::String(value.to_owned()));
633 map.insert(key.to_owned(), coerced);
634 }
635
636 Ok(map)
637}
638
639fn type_name(value: &Value) -> &'static str {
640 match value {
641 Value::Null => "null",
642 Value::Bool(_) => "a boolean",
643 Value::Number(_) => "a number",
644 Value::String(_) => "a string",
645 Value::Array(_) => "an array",
646 Value::Object(_) => "an object",
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 #[test]
655 fn json_params_are_merged() {
656 let params = parse_params(Some(r#"{"userId":"me","q":"is:unread"}"#), &[]).unwrap();
657 assert_eq!(params.get("userId").unwrap(), "me");
658 assert_eq!(params.get("q").unwrap(), "is:unread");
659 }
660
661 #[test]
662 fn param_flags_win_over_json_params() {
663 let params =
666 parse_params(Some(r#"{"userId":"me"}"#), &["userId=someone-else".into()]).unwrap();
667 assert_eq!(params.get("userId").unwrap(), "someone-else");
668 }
669
670 #[test]
671 fn param_values_are_coerced_to_json_types() {
672 let params =
673 parse_params(None, &["a=10".into(), "b=true".into(), "c=text".into()]).unwrap();
674 assert_eq!(params.get("a").unwrap(), 10);
675 assert_eq!(params.get("b").unwrap(), true);
676 assert_eq!(params.get("c").unwrap(), "text");
677 }
678
679 #[test]
680 fn values_containing_equals_are_preserved() {
681 let params = parse_params(None, &["q=name=a.pdf".into()]).unwrap();
683 assert_eq!(params.get("q").unwrap(), "name=a.pdf");
684 }
685
686 #[test]
687 fn malformed_input_is_rejected_with_guidance() {
688 assert!(parse_params(Some("not json"), &[]).is_err());
689 assert!(parse_params(Some("[1,2]"), &[]).is_err());
690 assert!(parse_params(None, &["novalue".into()]).is_err());
691 }
692
693 fn plan(id: &str, params: &[(&str, Value)]) -> (super::RequestPlan, &'static Method) {
694 let (service, method) = crate::discovery::resolve(id).unwrap();
695 let map: serde_json::Map<String, Value> = params
696 .iter()
697 .map(|(k, v)| ((*k).to_owned(), v.clone()))
698 .collect();
699 (
700 super::plan_request(service, method, &map, None, None, &[]).unwrap(),
701 method,
702 )
703 }
704
705 #[test]
706 fn plan_builds_the_url_from_discovery() {
707 let (plan, method) = plan(
708 "gmail.users.messages.list",
709 &[("userId", json!("me")), ("q", json!("is:unread"))],
710 );
711 assert_eq!(plan.verb, "GET");
712 assert_eq!(
713 plan.url,
714 "https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread"
715 );
716 assert_eq!(plan.body, None);
717 assert_eq!(method.id, "users.messages.list");
718 }
719
720 #[test]
721 fn plan_rejects_a_missing_required_parameter_by_name() {
722 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
723 let err = super::plan_request(service, method, &serde_json::Map::new(), None, None, &[])
724 .unwrap_err()
725 .to_string();
726 assert!(err.contains("missing required parameter `userId`"), "{err}");
727 }
728
729 #[test]
730 fn plan_appends_query_extras_after_discovery_pairs() {
731 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
732 let mut params = serde_json::Map::new();
733 params.insert("userId".into(), json!("me"));
734 params.insert("q".into(), json!("is:unread"));
735 let plan = super::plan_request(
736 service,
737 method,
738 ¶ms,
739 None,
740 None,
741 &["alt=json".to_owned()],
742 )
743 .unwrap();
744 assert!(plan.url.ends_with("q=is%3Aunread&alt=json"), "{}", plan.url);
745 }
746
747 #[test]
748 fn plan_rejects_malformed_query_extras() {
749 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
750 let mut params = serde_json::Map::new();
751 params.insert("userId".into(), json!("me"));
752 let err = super::plan_request(
753 service,
754 method,
755 ¶ms,
756 None,
757 None,
758 &["no-equals-sign".to_owned()],
759 )
760 .unwrap_err()
761 .to_string();
762 assert!(err.contains("KEY=VALUE"), "{err}");
763 }
764
765 #[test]
766 fn plan_uppercases_a_verb_override() {
767 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
768 let mut params = serde_json::Map::new();
769 params.insert("userId".into(), json!("me"));
770 let plan = super::plan_request(service, method, ¶ms, None, Some("post"), &[]).unwrap();
771 assert_eq!(plan.verb, "POST");
772 }
773
774 #[test]
775 fn plan_synthesizes_a_body_for_write_verbs_only() {
776 let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
779 let mut params = serde_json::Map::new();
780 params.insert("name".into(), json!("quarterly.pdf"));
781 let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
782 assert_eq!(plan.body, Some(json!({"name": "quarterly.pdf"})));
783
784 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
787 let mut params = serde_json::Map::new();
788 params.insert("userId".into(), json!("me"));
789 params.insert("q".into(), json!("is:unread"));
790 let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
791 assert_eq!(plan.body, None);
792 assert!(plan.url.contains("q=is%3Aunread"));
793 }
794
795 #[test]
796 fn plan_parses_json_bodies_and_wraps_the_rest() {
797 let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
801 let plan = super::plan_request(
802 service,
803 method,
804 &serde_json::Map::new(),
805 Some(r#"{"name":"uploaded.pdf"}"#),
806 None,
807 &[],
808 )
809 .unwrap();
810 assert_eq!(plan.body, Some(json!({"name": "uploaded.pdf"})));
811
812 let plan = super::plan_request(
813 service,
814 method,
815 &serde_json::Map::new(),
816 Some("not json at all"),
817 None,
818 &[],
819 )
820 .unwrap();
821 assert_eq!(plan.body, Some(json!("not json at all")));
822 }
823
824 #[tokio::test]
825 async fn read_body_source_reads_a_file() {
826 let dir = tempfile::tempdir().unwrap();
827 let path = dir.path().join("body.json");
828 tokio::fs::write(&path, r#"{"a":1}"#).await.unwrap();
829 let raw = super::read_body_source(path.to_str().unwrap())
830 .await
831 .unwrap();
832 assert_eq!(raw, r#"{"a":1}"#);
833 }
834
835 #[test]
836 fn dry_run_payload_is_a_pure_function_of_the_plan() {
837 let (plan, method) = plan("gmail.users.messages.list", &[("userId", json!("me"))]);
838 let payload = super::dry_run_payload(method, &plan);
839 assert_eq!(payload["method"], "GET");
840 assert_eq!(
841 payload["url"],
842 "https://gmail.googleapis.com/gmail/v1/users/me/messages"
843 );
844 assert_eq!(payload["dryRun"], true);
845 assert!(
846 payload["leastPrivilegeScope"]
847 .as_str()
848 .unwrap()
849 .ends_with("gmail.readonly")
850 );
851 }
852
853 async fn test_auth() -> GoogleAuth {
854 let config = crate::core::config::OAuthConfig {
855 client_id: "test-id.apps.googleusercontent.com".into(),
856 client_secret: None,
857 ..Default::default()
858 };
859 let storage = crate::core::auth::TokenStorage {
860 access_token: "test-token".into(),
861 refresh_token: None,
862 expires_at: u64::MAX,
863 token_type: "Bearer".into(),
864 scope: String::new(),
865 };
866 GoogleAuth::with_token(config, storage).await.unwrap()
867 }
868
869 #[tokio::test]
870 async fn handle_call_refuses_a_write_under_readonly() {
871 let auth = test_auth().await;
874 let args = ApiCallArgs {
875 method: "gmail.users.messages.send".into(),
876 params: None,
877 param: vec![],
878 body_file: None,
879 method_override: None,
880 query: vec![],
881 dry_run: true,
882 format: OutputFormat::Json,
883 };
884 let err = handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
885 .await
886 .unwrap_err()
887 .to_string();
888 assert!(err.contains("POST"), "{err}");
889 assert!(err.contains("--readonly"), "{err}");
890 assert!(err.contains("Rerun without --readonly"), "{err}");
891 }
892
893 #[tokio::test]
894 async fn handle_call_refuses_a_denied_service_and_a_denied_verb() {
895 let auth = test_auth().await;
896 let args = ApiCallArgs {
897 method: "gmail.users.messages.list".into(),
898 params: None,
899 param: vec![],
900 body_file: None,
901 method_override: None,
902 query: vec![],
903 dry_run: true,
904 format: OutputFormat::Json,
905 };
906 let profile = SafetyProfile::new(false, vec!["gmail".to_owned()], vec![]);
907 let err = handle_call(Some(&auth), args, &profile)
908 .await
909 .unwrap_err()
910 .to_string();
911 assert!(err.contains("--deny-service gmail"), "{err}");
912
913 let args = ApiCallArgs {
914 method: "gmail.users.messages.list".into(),
915 params: None,
916 param: vec![],
917 body_file: None,
918 method_override: None,
919 query: vec![],
920 dry_run: true,
921 format: OutputFormat::Json,
922 };
923 let profile = SafetyProfile::new(false, vec![], vec!["get".to_owned()]);
924 let err = handle_call(Some(&auth), args, &profile)
925 .await
926 .unwrap_err()
927 .to_string();
928 assert!(err.contains("--deny-verb GET"), "{err}");
929 }
930
931 #[tokio::test]
932 async fn handle_call_runs_a_read_under_readonly() {
933 let auth = test_auth().await;
934 let args = ApiCallArgs {
935 method: "gmail.users.messages.list".into(),
936 params: None,
937 param: vec!["userId=me".into()],
938 body_file: None,
939 method_override: None,
940 query: vec![],
941 dry_run: true,
942 format: OutputFormat::Json,
943 };
944 assert!(
946 handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
947 .await
948 .is_ok()
949 );
950 }
951}