Skip to main content

grr_cli/commands/
api.rs

1//! `grr api` — the discovery-driven escape hatch.
2//!
3//! `grr api call <id>` reaches every method Google publishes across the
4//! 14 APIs in the committed Discovery index — including anything released
5//! after grr was built, with no code change and no new release. The
6//! generated service tree
7//! (`src/commands/generated.rs`, dispatched by `gen_dispatch.rs`) is the
8//! same engine with a friendlier shape: both funnel through
9//! [`call_method`], so a dry-run of the same method and parameters is
10//! byte-identical either way.
11//!
12//! ```text
13//! grr api list                                    # every method
14//! grr api list gmail --filter list                # just Gmail listing methods
15//! grr api describe gmail.users.messages.list      # params + scopes
16//! grr api call gmail.users.messages.list --param userId=me --param 'q=is:unread'
17//! grr api call drive.files.list --params '{"pageSize":10}'
18//! grr gmail users messages list --user-id me      # the same call, generated
19//! ```
20//!
21//! The login, not this command, decides the grant: `grr auth login` requests
22//! a small 11-scope default, `--service <name>` adds one service's scopes and
23//! `--all-scopes` the whole index — the sets and the reasoning behind them
24//! live in `core::auth`. A method outside the current grant answers 403
25//! naming the scope; widen the grant and retry.
26
27use 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 every method the Discovery index knows about
38    List(ApiListArgs),
39    /// Show one method's parameters, scopes and documentation
40    Describe(ApiDescribeArgs),
41    /// Invoke a method
42    Call(ApiCallArgs),
43    /// Fetch fresh Discovery documents into the local cache. The embedded
44    /// index keeps working offline; this pulls it forward between releases.
45    Refresh(ApiRefreshArgs),
46}
47
48#[derive(Args, Debug)]
49pub struct ApiRefreshArgs {
50    /// Refresh one service only (gmail, calendar, drive, people, chat,
51    /// forms, tasks, docs, sheets, slides, script, analyticsadmin,
52    /// analyticsdata, searchconsole)
53    #[arg(long)]
54    pub service: Option<String>,
55
56    /// Output format
57    #[arg(short, long, value_enum, default_value = "json")]
58    pub format: OutputFormat,
59}
60
61#[derive(Args, Debug)]
62pub struct ApiListArgs {
63    /// Limit to one service (gmail, calendar, drive, people, chat, forms,
64    /// tasks, docs, sheets, slides, script, analyticsadmin, analyticsdata,
65    /// searchconsole)
66    #[arg(long)]
67    pub service: Option<String>,
68
69    /// Case-insensitive substring filter over method ids and descriptions
70    #[arg(long)]
71    pub filter: Option<String>,
72
73    /// Group output by service instead of one flat list
74    #[arg(long)]
75    pub grouped: bool,
76
77    /// Output format
78    #[arg(short, long, value_enum, default_value = "json")]
79    pub format: OutputFormat,
80}
81
82#[derive(Args, Debug)]
83pub struct ApiDescribeArgs {
84    /// Method id, e.g. gmail.users.messages.list
85    pub method: String,
86
87    /// Output format
88    #[arg(short, long, value_enum, default_value = "json")]
89    pub format: OutputFormat,
90}
91
92#[derive(Args, Debug)]
93pub struct ApiCallArgs {
94    /// Method id, e.g. gmail.users.messages.list
95    pub method: String,
96
97    /// Parameters as a JSON object. Values for path placeholders are
98    /// substituted into the URL; the rest become query parameters, or the
99    /// request body for POST/PATCH/PUT.
100    #[arg(long, value_name = "JSON")]
101    pub params: Option<String>,
102
103    /// A single parameter as key=value. Repeatable. Use for values that are
104    /// awkward to quote in JSON.
105    #[arg(long = "param", value_name = "KEY=VALUE")]
106    pub param: Vec<String>,
107
108    /// Read the request body from a file ("-" for stdin) instead of
109    /// building one from --params. Sends the bytes verbatim.
110    #[arg(long, value_name = "PATH")]
111    pub body_file: Option<String>,
112
113    /// Override the HTTP method from the Discovery document
114    #[arg(long, value_name = "VERB")]
115    pub method_override: Option<String>,
116
117    /// Extra fields to merge into the query string, for endpoints whose
118    /// parameters Discovery does not describe (alt=json, fields, key)
119    #[arg(long = "query", value_name = "KEY=VALUE")]
120    pub query: Vec<String>,
121
122    /// Print the request that would be sent without sending it
123    #[arg(long)]
124    pub dry_run: bool,
125
126    // NOTE: the safety flags (--readonly, --deny-service, --deny-verb) are
127    // NOT redeclared here: they are global args on the root command
128    // (cli.rs), and a second declaration with the same IDs would panic
129    // clap's arg-matching. Global args propagate into this subcommand's
130    // matches, so from_arg_matches still fills them.
131    /// Output format
132    #[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        // Introspection over the embedded index: no credential involved.
143        ApiCommands::List(args) => handle_list(args)?,
144        ApiCommands::Describe(args) => handle_describe(args)?,
145        ApiCommands::Call(args) => {
146            // The credential check lives in the call path (call_method),
147            // not here: --dry-run is a plan-only operation that must work
148            // with no OAuth client at all, while a real send reports the
149            // actionable NO_CLIENT_HELP. Pass the Option straight through.
150            handle_call(auth, args, profile).await?
151        }
152        // The refresh needs no credential: Discovery documents are public.
153        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    // stdout stays machine-readable; the summary goes to stderr.
166    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    // stderr keeps stdout pure JSON so `grr api list | jq` works.
256    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            // The raw template, not a rendered URL: placeholders are the
279            // point, and the caller can see which values are required.
280            "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    // The safety gate runs before anything is built or sent — the same
321    // gate the `grr mcp` server honors (src/commands/safety.rs). The
322    // profile comes from the global --readonly/--deny-service/--deny-verb
323    // flags, parsed once in cli.rs.
324    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/// The caller-controlled request shaping beyond the parameter map. All
347/// optional; `grr api call` fills everything, the generated tree leaves
348/// `verb_override` at `None`, `auth status` leaves almost all of it.
349#[derive(Debug, Default)]
350pub(crate) struct CallOptions {
351    /// Read the request body from this file ("-" = stdin) instead of
352    /// synthesising one from the parameters.
353    pub body_file: Option<String>,
354    /// Override the HTTP method from the Discovery document
355    /// (`grr api call --method-override`).
356    pub verb_override: Option<String>,
357    /// Extra KEY=VALUE query pairs for undocumented parameters.
358    pub query_extras: Vec<String>,
359    /// Print the request that would be sent without sending it.
360    pub dry_run: bool,
361}
362
363/// Everything needed to issue (or dry-run) one Discovery method call.
364/// The URL is fully built; the body is a parsed JSON value (or the raw
365/// file/stdin bytes wrapped as a JSON string when they were not JSON).
366#[derive(Debug)]
367pub(crate) struct RequestPlan {
368    pub verb: String,
369    pub url: String,
370    pub body: Option<Value>,
371}
372
373/// Validate parameters against the method and build the request. Pure:
374/// no network, no credentials — which is what keeps the dry-run output
375/// and the parity tests honest.
376///
377/// `query_extras` are appended after the Discovery-derived query pairs,
378/// for endpoints whose parameters Discovery does not describe
379/// (`alt=json`, `fields`, `key`).
380pub(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    // Warn loudly rather than letting Google return a confusing 400.
389    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    // With an already-read body, the caller's bytes win; otherwise synthesise
434    // a body from the non-path, non-query parameters for verbs that take one.
435    // No I/O happens here: the caller reads the source asynchronously (see
436    // read_body_source), which keeps this planner pure and testable.
437    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
467/// The `--dry-run` payload for a planned request. Shared by `grr api call`
468/// and the generated tree so both print byte-identical output.
469pub(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
480/// Read a `--body-file` source into memory; `-` means stdin.
481///
482/// Async on purpose: this runs on the shared call path (`grr api call`, the
483/// generated tree, `grr ask --run`, every MCP tools/call). A blocking read
484/// would stall an executor worker, and the MCP server is a strictly
485/// sequential stdio loop — a slow pipe on `--body-file -` would stall the
486/// whole server. `plan_request` stays pure; this is the one I/O step before
487/// it.
488async 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
502/// THE shared call path: `grr api call`, the generated service tree and
503/// `grr auth status` all issue Discovery methods through this function.
504///
505/// Returns the payload for the caller to print with its chosen format: a
506/// dry-run returns its plan payload, a live call the parsed response body.
507pub(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        &params,
522        body_raw.as_deref(),
523        options.verb_override.as_deref(),
524        &options.query_extras,
525    )?;
526
527    if options.dry_run {
528        // The plan is the whole answer: no credential is consulted, so a
529        // fresh CI runner (or a first-time user exploring with --dry-run)
530        // sees the exact request without any OAuth client configured.
531        return Ok(dry_run_payload(method, &plan));
532    }
533
534    // Sending is the first moment a credential exists at all.
535    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        // 403 with insufficient_scope is the common case when a method
565        // needs a scope this build did not request; name it. Google's own
566        // message tells the three real causes apart ("Request had
567        // insufficient authentication scopes" vs "… API has not been used in
568        // project … or it is disabled"), so quote it rather than replace it.
569        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
605/// Merge `--params` JSON with KEY=VALUE pairs (and, in the generated tree,
606/// with the typed flags). Shared with the generated tree's dispatch: the
607/// later, more specific source always wins, and values that parse as JSON
608/// keep their real type so `--param pageSize=10` is a number.
609pub(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        // Bare `true`/`123`/`[...]` become real JSON values, so `--param
629        // pageSize=10` is a number and not the string "10" — otherwise
630        // every numeric parameter would need quoting.
631        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        // The later, more specific source should win so a caller can
664        // override one field of a large --params blob.
665        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        // Drive queries and Gmail searches contain '='; only split once.
682        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            &params,
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            &params,
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, &params, 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        // drive.files.create is a POST: leftover (non-path) params become
777        // the request body, exactly as `grr api call` always did.
778        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, &params, None, None, &[]).unwrap();
782        assert_eq!(plan.body, Some(json!({"name": "quarterly.pdf"})));
783
784        // A GET never gets a body, even with leftover params — they ride
785        // in the query string instead.
786        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, &params, 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        // The planner receives already-read bytes (read_body_source owns the
798        // I/O): JSON parses, anything else is wrapped as a string, never
799        // rejected.
800        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        // The gate fires before anything is built or sent, so the dummy
872        // auth handle is never used.
873        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        // A GET is not a write: the dry-run returns instead of refusing.
945        assert!(
946            handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
947                .await
948                .is_ok()
949        );
950    }
951}