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 std::collections::BTreeMap;
28use std::sync::{Mutex, MutexGuard};
29
30use crate::commands::safety::SafetyProfile;
31use crate::core::auth::GoogleAuth;
32use crate::core::http::HttpCore;
33use crate::discovery::{self, Method, Service};
34use crate::output::{OutputFormat, print_output};
35use anyhow::{Context, Result, bail};
36use clap::{Args, Subcommand};
37use serde_json::{Value, json};
38
39#[derive(Subcommand, Debug)]
40pub enum ApiCommands {
41    /// List every method the Discovery index knows about
42    List(ApiListArgs),
43    /// Show one method's parameters, scopes and documentation
44    Describe(ApiDescribeArgs),
45    /// Invoke a method
46    Call(ApiCallArgs),
47    /// Fetch fresh Discovery documents into the local cache. The embedded
48    /// index keeps working offline; this pulls it forward between releases.
49    Refresh(ApiRefreshArgs),
50}
51
52#[derive(Args, Debug)]
53pub struct ApiRefreshArgs {
54    /// Refresh one service only (gmail, calendar, drive, people, chat,
55    /// forms, tasks, docs, sheets, slides, script, analyticsadmin,
56    /// analyticsdata, searchconsole)
57    #[arg(long)]
58    pub service: Option<String>,
59
60    /// Output format
61    #[arg(short, long, value_enum, default_value = "json")]
62    pub format: OutputFormat,
63}
64
65#[derive(Args, Debug)]
66pub struct ApiListArgs {
67    /// Limit to one service (gmail, calendar, drive, people, chat, forms,
68    /// tasks, docs, sheets, slides, script, analyticsadmin, analyticsdata,
69    /// searchconsole)
70    #[arg(long)]
71    pub service: Option<String>,
72
73    /// Case-insensitive substring filter over method ids and descriptions
74    #[arg(long)]
75    pub filter: Option<String>,
76
77    /// Group output by service instead of one flat list
78    #[arg(long)]
79    pub grouped: bool,
80
81    /// Output format
82    #[arg(short, long, value_enum, default_value = "json")]
83    pub format: OutputFormat,
84}
85
86#[derive(Args, Debug)]
87pub struct ApiDescribeArgs {
88    /// Method id, e.g. gmail.users.messages.list
89    pub method: String,
90
91    /// Output format
92    #[arg(short, long, value_enum, default_value = "json")]
93    pub format: OutputFormat,
94}
95
96#[derive(Args, Debug)]
97pub struct ApiCallArgs {
98    /// Method id, e.g. gmail.users.messages.list
99    pub method: String,
100
101    /// Parameters as a JSON object. Values for path placeholders are
102    /// substituted into the URL; the rest become query parameters, or the
103    /// request body for POST/PATCH/PUT.
104    #[arg(long, value_name = "JSON")]
105    pub params: Option<String>,
106
107    /// A single parameter as key=value. Repeatable. Use for values that are
108    /// awkward to quote in JSON.
109    #[arg(long = "param", value_name = "KEY=VALUE")]
110    pub param: Vec<String>,
111
112    /// Read the request body from a file ("-" for stdin) instead of
113    /// building one from --params. Sends the bytes verbatim.
114    #[arg(long, value_name = "PATH")]
115    pub body_file: Option<String>,
116
117    /// Override the HTTP method from the Discovery document
118    #[arg(long, value_name = "VERB")]
119    pub method_override: Option<String>,
120
121    /// Extra fields to merge into the query string, for endpoints whose
122    /// parameters Discovery does not describe (alt=json, fields, key)
123    #[arg(long = "query", value_name = "KEY=VALUE")]
124    pub query: Vec<String>,
125
126    /// Print the request that would be sent without sending it
127    #[arg(long)]
128    pub dry_run: bool,
129
130    // NOTE: the safety flags (--readonly, --deny-service, --deny-verb) are
131    // NOT redeclared here: they are global args on the root command
132    // (cli.rs), and a second declaration with the same IDs would panic
133    // clap's arg-matching. Global args propagate into this subcommand's
134    // matches, so from_arg_matches still fills them.
135    /// Output format
136    #[arg(short, long, value_enum, default_value = "json")]
137    pub format: OutputFormat,
138}
139
140pub async fn handle_api_cmd(
141    auth: Option<&GoogleAuth>,
142    cmd: ApiCommands,
143    profile: &SafetyProfile,
144) -> Result<()> {
145    match cmd {
146        // Introspection over the embedded index: no credential involved.
147        ApiCommands::List(args) => handle_list(args)?,
148        ApiCommands::Describe(args) => handle_describe(args)?,
149        ApiCommands::Call(args) => {
150            // The credential check lives in the call path (call_method),
151            // not here: --dry-run is a plan-only operation that must work
152            // with no OAuth client at all, while a real send reports the
153            // actionable NO_CLIENT_HELP. Pass the Option straight through.
154            handle_call(auth, args, profile).await?
155        }
156        // The refresh needs no credential: Discovery documents are public.
157        ApiCommands::Refresh(args) => handle_refresh(args).await?,
158    }
159    Ok(())
160}
161
162async fn handle_refresh(args: ApiRefreshArgs) -> Result<()> {
163    let reports = crate::discovery::refresh(args.service.as_deref())
164        .await
165        .map_err(anyhow::Error::msg)?;
166
167    let refreshed = reports.iter().filter(|r| r.wrote_cache).count();
168    let skipped = reports.iter().filter(|r| r.skipped_older).count();
169    // stdout stays machine-readable; the summary goes to stderr.
170    eprintln!("{refreshed} refreshed, {skipped} already current");
171    print_output(
172        &serde_json::json!({
173            "refreshed": refreshed,
174            "alreadyCurrent": skipped,
175            "services": reports.iter().map(|r| serde_json::json!({
176                "service": r.service,
177                "from": r.from,
178                "to": r.to,
179                "methods": r.methods,
180                "wroteCache": r.wrote_cache,
181                "skippedOlder": r.skipped_older,
182            })).collect::<Vec<_>>(),
183            "nextStep": "restart grr (each invocation is a fresh process) to use the refreshed index",
184        }),
185        args.format,
186    )?;
187    Ok(())
188}
189
190fn matches_filter(method: &Method, needle: &str) -> bool {
191    let needle = needle.to_ascii_lowercase();
192    method.id.to_ascii_lowercase().contains(&needle)
193        || method.description.to_ascii_lowercase().contains(&needle)
194}
195
196fn handle_list(args: ApiListArgs) -> Result<()> {
197    let all = discovery::services();
198    let selected: Vec<(&&str, &Service)> = match &args.service {
199        Some(name) => {
200            let service = all.get_key_value(name.as_str()).ok_or_else(|| {
201                let known: Vec<&str> = all.keys().copied().collect();
202                anyhow::anyhow!("unknown service `{name}`; known: {}", known.join(", "))
203            })?;
204            vec![service]
205        }
206        None => all.iter().collect(),
207    };
208
209    let filtered: Vec<(&&str, &Service, Vec<&Method>)> = selected
210        .into_iter()
211        .map(|(name, service)| {
212            let methods: Vec<&Method> = match &args.filter {
213                Some(needle) => service
214                    .methods
215                    .iter()
216                    .filter(|m| matches_filter(m, needle))
217                    .collect(),
218                None => service.methods.iter().collect(),
219            };
220            (name, service, methods)
221        })
222        .filter(|(_, _, methods)| !methods.is_empty())
223        .collect();
224
225    if args.grouped {
226        let payload: Vec<Value> = filtered
227            .iter()
228            .map(|(_, service, methods)| {
229                json!({
230                    "service": service.name,
231                    "version": service.version,
232                    "revision": service.revision,
233                    "count": methods.len(),
234                    "methods": methods.iter().map(|m| json!({
235                        "id": format!("{}.{}", service.name, m.id),
236                        "http": m.http_method,
237                    })).collect::<Vec<_>>(),
238                })
239            })
240            .collect();
241        print_output(&Value::Array(payload), args.format)?;
242        return Ok(());
243    }
244
245    let total: usize = filtered.iter().map(|(_, _, m)| m.len()).sum();
246    let payload: Vec<Value> = filtered
247        .iter()
248        .flat_map(|(_, service, methods)| {
249            methods.iter().map(move |m| {
250                json!({
251                    "id": format!("{}.{}", service.name, m.id),
252                    "http": m.http_method,
253                    "summary": first_sentence(&m.description),
254                })
255            })
256        })
257        .collect();
258
259    // stderr keeps stdout pure JSON so `grr api list | jq` works.
260    eprintln!("{total} methods");
261    print_output(&Value::Array(payload), args.format)?;
262    Ok(())
263}
264
265fn first_sentence(text: &str) -> String {
266    match text.find(". ") {
267        Some(idx) => text[..idx + 1].to_owned(),
268        None => text.chars().take(120).collect(),
269    }
270}
271
272fn handle_describe(args: ApiDescribeArgs) -> Result<()> {
273    let (service, method) = discovery::resolve(&args.method).map_err(anyhow::Error::msg)?;
274    print_output(
275        &json!({
276            "id": format!("{}.{}", service.name, method.id),
277            "service": service.name,
278            "apiVersion": service.version,
279            "discoveryRevision": service.revision,
280            "httpMethod": method.http_method,
281            "path": method.path,
282            // The raw template, not a rendered URL: placeholders are the
283            // point, and the caller can see which values are required.
284            "urlTemplate": format!(
285                "{}{}{}",
286                service.root_url.as_deref().unwrap_or_default(),
287                service.base(),
288                method.path
289            ),
290            "description": method.description,
291            "scopes": method.scopes,
292            "leastPrivilegeScope": method.least_privilege_scope(),
293            "parameters": method.parameters.iter().map(|p| json!({
294                "name": p.name,
295                "type": p.kind,
296                "required": p.required,
297                "repeated": p.repeated,
298                "description": p.description,
299                "enum": p.enum_values,
300            })).collect::<Vec<_>>(),
301            "hint": format!(
302                "grr api call {}.{} --params '{{\"{}\": \"...\"}}'",
303                service.name, method.id,
304                method.parameters.first().map(|p| p.name.as_str()).unwrap_or("userId")
305            ),
306        }),
307        args.format,
308    )?;
309    Ok(())
310}
311
312async fn handle_call(
313    auth: Option<&GoogleAuth>,
314    args: ApiCallArgs,
315    profile: &SafetyProfile,
316) -> Result<()> {
317    let (service, method) = discovery::resolve(&args.method).map_err(|message| {
318        anyhow::anyhow!(
319            "{message}\n\nRun `grr api list {}` to see what is available.",
320            service_hint(&args.method)
321        )
322    })?;
323
324    // The safety gate runs before anything is built or sent — the same
325    // gate the `grr mcp` server honors (src/commands/safety.rs). The
326    // profile comes from the global --readonly/--deny-service/--deny-verb
327    // flags, parsed once in cli.rs.
328    if let Err(message) = profile.check(&service.name, method) {
329        bail!("{message}");
330    }
331
332    let params = parse_params(args.params.as_deref(), &args.param)?;
333    let payload = call_method(
334        auth,
335        service,
336        method,
337        params,
338        CallOptions {
339            body_file: args.body_file,
340            verb_override: args.method_override,
341            query_extras: args.query,
342            dry_run: args.dry_run,
343        },
344    )
345    .await?;
346    print_output(&payload, args.format)?;
347    Ok(())
348}
349
350/// The caller-controlled request shaping beyond the parameter map. All
351/// optional; `grr api call` fills everything, the generated tree leaves
352/// `verb_override` at `None`, `auth status` leaves almost all of it.
353#[derive(Debug, Default)]
354pub(crate) struct CallOptions {
355    /// Read the request body from this file ("-" = stdin) instead of
356    /// synthesising one from the parameters.
357    pub body_file: Option<String>,
358    /// Override the HTTP method from the Discovery document
359    /// (`grr api call --method-override`).
360    pub verb_override: Option<String>,
361    /// Extra KEY=VALUE query pairs for undocumented parameters.
362    pub query_extras: Vec<String>,
363    /// Print the request that would be sent without sending it.
364    pub dry_run: bool,
365}
366
367/// Everything needed to issue (or dry-run) one Discovery method call.
368/// The URL is fully built; the body is a parsed JSON value (or the raw
369/// file/stdin bytes wrapped as a JSON string when they were not JSON).
370#[derive(Debug)]
371pub(crate) struct RequestPlan {
372    pub verb: String,
373    pub url: String,
374    pub body: Option<Value>,
375}
376
377/// Validate parameters against the method and build the request. Pure:
378/// no network, no credentials — which is what keeps the dry-run output
379/// and the parity tests honest.
380///
381/// `query_extras` are appended after the Discovery-derived query pairs,
382/// for endpoints whose parameters Discovery does not describe
383/// (`alt=json`, `fields`, `key`).
384pub(crate) fn plan_request(
385    service: &Service,
386    method: &Method,
387    params: &serde_json::Map<String, Value>,
388    body_raw: Option<&str>,
389    verb_override: Option<&str>,
390    query_extras: &[String],
391) -> Result<RequestPlan> {
392    // Warn loudly rather than letting Google return a confusing 400.
393    for required in &method.required {
394        if !params.contains_key(required) {
395            let known: Vec<String> = method
396                .parameters
397                .iter()
398                .filter(|p| p.required)
399                .map(|p| format!("{} ({})", p.name, p.kind))
400                .collect();
401            bail!(
402                "missing required parameter `{required}` for {}.{}\nRequired: {}",
403                service.name,
404                method.id,
405                if known.is_empty() {
406                    "none".to_owned()
407                } else {
408                    known.join(", ")
409                }
410            );
411        }
412    }
413
414    let verb = verb_override
415        .unwrap_or(&method.http_method)
416        .to_ascii_uppercase();
417    let path = discovery::build_path(service, method, params).map_err(anyhow::Error::msg)?;
418    let mut query = discovery::build_query(method, params);
419    for extra in query_extras {
420        let Some((k, v)) = extra.split_once('=') else {
421            bail!("--query expects KEY=VALUE, got `{extra}`");
422        };
423        if !query.is_empty() {
424            query.push('&');
425        }
426        query.push_str(&urlencoding::encode(k));
427        query.push('=');
428        query.push_str(&urlencoding::encode(v));
429    }
430    let url = format!(
431        "{}{}{}",
432        service.root_url.as_deref().unwrap_or_default(),
433        path,
434        query
435    );
436
437    // With an already-read body, the caller's bytes win; otherwise synthesise
438    // a body from the non-path, non-query parameters for verbs that take one.
439    // No I/O happens here: the caller reads the source asynchronously (see
440    // read_body_source), which keeps this planner pure and testable.
441    let body: Option<Value> = match body_raw {
442        Some(raw) => {
443            Some(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_owned())))
444        }
445        None => {
446            let placeholders = method.path_placeholders();
447            let is_write = matches!(verb.as_str(), "POST" | "PATCH" | "PUT");
448            if !is_write {
449                None
450            } else {
451                let mut rest = serde_json::Map::new();
452                for (key, value) in params {
453                    if placeholders.contains(&key.as_str()) {
454                        continue;
455                    }
456                    if let Some(param) = method.parameter(key)
457                        && param.location.as_deref() == Some("path")
458                    {
459                        continue;
460                    }
461                    rest.insert(key.clone(), value.clone());
462                }
463                (!rest.is_empty()).then_some(Value::Object(rest))
464            }
465        }
466    };
467
468    Ok(RequestPlan { verb, url, body })
469}
470
471/// The `--dry-run` payload for a planned request. Shared by `grr api call`
472/// and the generated tree so both print byte-identical output.
473pub(crate) fn dry_run_payload(method: &Method, plan: &RequestPlan) -> Value {
474    json!({
475        "method": plan.verb,
476        "url": plan.url,
477        "body": plan.body,
478        "scopesRequested": method.scopes,
479        "leastPrivilegeScope": method.least_privilege_scope(),
480        "dryRun": true,
481    })
482}
483
484/// Read a `--body-file` source into memory; `-` means stdin.
485///
486/// Async on purpose: this runs on the shared call path (`grr api call`, the
487/// generated tree, `grr ask --run`, every MCP tools/call). A blocking read
488/// would stall an executor worker, and the MCP server is a strictly
489/// sequential stdio loop — a slow pipe on `--body-file -` would stall the
490/// whole server. `plan_request` stays pure; this is the one I/O step before
491/// it.
492async fn read_body_source(source: &str) -> Result<String> {
493    if source == "-" {
494        let mut buf = String::new();
495        tokio::io::AsyncReadExt::read_to_string(&mut tokio::io::stdin(), &mut buf)
496            .await
497            .with_context(|| "reading body from stdin")?;
498        Ok(buf)
499    } else {
500        tokio::fs::read_to_string(source)
501            .await
502            .with_context(|| format!("reading body from {source}"))
503    }
504}
505
506/// One request engine per service base URL, for the process lifetime.
507///
508/// [`HttpCore::connect`] probes the transport with an authenticated round
509/// trip, so the probe is paid once per host per process rather than once per
510/// call: `grr ask --run` and the `grr mcp` server issue many calls in one
511/// process. `--dry-run` returns before this is ever touched, so planning
512/// stays network-free. The cache is a const-constructible
513/// `Mutex<BTreeMap>`, and a process carries one credential handle (the
514/// `--account` rebind happens before any call), so keying on the base URL
515/// alone cannot mix accounts. The index covers 14 services, so a handful of
516/// origins is the whole population — [`MAX_ENGINES`] only closes the
517/// theoretical hole of caller-supplied hosts.
518static CORES: Mutex<BTreeMap<String, HttpCore>> = Mutex::new(BTreeMap::new());
519
520/// Bound on the engine cache. Real usage is a handful of Google origins;
521/// evicting the oldest (BTreeMap order = lexicographic) keeps a hypothetical
522/// caller-driven host explosion from pinning clients forever.
523const MAX_ENGINES: usize = 32;
524
525/// Probe path for the data plane: the service root. Any HTTP response — even
526/// a 404 — proves the transport negotiated, and no cheaper path is shared by
527/// every API in the index.
528const PROBE_PATH: &str = "";
529
530/// The engine for one service origin, probed on first use, reused after.
531///
532/// Two concurrent misses probe twice and the last one wins: both probes are
533/// one extra GET to the same host, which beats serialising every call of a
534/// burst behind this lock. A failed connect evicts any cached engine for
535/// that origin first, so a transport change (network switch, QUIC blocked
536/// mid-session) rebuilds instead of pinning the dead engine forever.
537async fn engine_for(auth: &GoogleAuth, base: &url::Url) -> Result<HttpCore> {
538    let key = base.as_str().to_owned();
539    if let Some(engine) = cores().get(&key) {
540        return Ok(engine.clone());
541    }
542    match HttpCore::connect(auth.clone(), base, PROBE_PATH).await {
543        Ok(engine) => {
544            let mut cores = cores();
545            while cores.len() >= MAX_ENGINES {
546                cores.pop_first();
547            }
548            cores.insert(key, engine.clone());
549            Ok(engine)
550        }
551        Err(error) => {
552            cores().remove(&key);
553            Err(error.into())
554        }
555    }
556}
557
558/// The engine cache. Poisoning is ignored on purpose: an engine that
559/// panicked mid-request must not wedge every later call in the process.
560fn cores() -> MutexGuard<'static, BTreeMap<String, HttpCore>> {
561    CORES
562        .lock()
563        .unwrap_or_else(|poisoned| poisoned.into_inner())
564}
565
566/// THE shared call path: `grr api call`, the generated service tree and
567/// `grr auth status` all issue Discovery methods through this function.
568///
569/// Returns the payload for the caller to print with its chosen format: a
570/// dry-run returns its plan payload, a live call the parsed response body.
571pub(crate) async fn call_method(
572    auth: Option<&GoogleAuth>,
573    service: &Service,
574    method: &Method,
575    params: serde_json::Map<String, Value>,
576    options: CallOptions,
577) -> Result<Value> {
578    let body_raw = match options.body_file.as_deref() {
579        Some(source) => Some(read_body_source(source).await?),
580        None => None,
581    };
582    let plan = plan_request(
583        service,
584        method,
585        &params,
586        body_raw.as_deref(),
587        options.verb_override.as_deref(),
588        &options.query_extras,
589    )?;
590
591    if options.dry_run {
592        // The plan is the whole answer: no credential is consulted, so a
593        // fresh CI runner (or a first-time user exploring with --dry-run)
594        // sees the exact request without any OAuth client configured.
595        return Ok(dry_run_payload(method, &plan));
596    }
597
598    // Sending is the first moment a credential exists at all.
599    let auth = auth.ok_or_else(|| anyhow::anyhow!("{}", crate::core::config::NO_CLIENT_HELP))?;
600
601    let parsed_url = url::Url::parse(&plan.url).with_context(|| {
602        format!(
603            "`{}` is not a valid URL (Discovery path + parameters)",
604            plan.url
605        )
606    })?;
607
608    // The data plane rides `HttpCore`, the engine `grr transport` reports on:
609    // bearer auth, the HTTP/3 transport (HTTP/2 prior knowledge on the
610    // fallback path), the in-flight valve and the retry policy — 429 with
611    // `Retry-After`, 401 with the `grr auth login` hint. The engine is probed
612    // once per origin and cached for the rest of the process.
613    let engine = engine_for(auth, &parsed_url.join("/")?).await?;
614
615    let mut request = engine.http_client().request(
616        reqwest::Method::from_bytes(plan.verb.as_bytes()).unwrap_or(reqwest::Method::GET),
617        parsed_url,
618    );
619    if let Some(payload) = &plan.body {
620        request = request.json(payload);
621    }
622    let response = engine.execute(request).await?;
623
624    let status = response.status();
625    let text = response.text().await.unwrap_or_default();
626    let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()));
627
628    if !status.is_success() {
629        bail!(
630            "{}",
631            api_failure_message(&plan, status.as_u16(), &parsed, &text, method)
632        );
633    }
634
635    Ok(parsed)
636}
637
638/// The error message for a non-success response on the shared call path.
639///
640/// 403 with insufficient_scope is the common case when a method needs a
641/// scope this build did not request, so it names the scope; Google's own
642/// message tells the three real causes apart ("Request had insufficient
643/// authentication scopes" vs "… API has not been used in project … or it is
644/// disabled"), so it is quoted rather than replaced. Everything else keeps
645/// the long-standing `VERB url -> HTTP <status>` shape with Google's body
646/// pretty-printed — scripts grep for it.
647pub(crate) fn api_failure_message(
648    plan: &RequestPlan,
649    status: u16,
650    parsed: &Value,
651    text: &str,
652    method: &Method,
653) -> String {
654    if status == 403
655        && let Some(scope) = method.least_privilege_scope()
656    {
657        let detail = parsed["error"]["message"].as_str().unwrap_or("");
658        let google_said = if detail.is_empty() {
659            String::new()
660        } else {
661            format!("\nGoogle said: {detail}")
662        };
663        return format!(
664            "{} {} -> HTTP 403: this method needs the `{scope}` scope.\n\
665             This build requests every scope the embedded index names, so the grant is what \
666             to check: a token that predates the current set (re-run `grr auth login`), a \
667             scope the project's consent screen never declared (Google Auth Platform → Data \
668             access — undeclared scopes are dropped silently at consent), or an API that is \
669             disabled on the Cloud project.{google_said}",
670            plan.verb, plan.url
671        );
672    }
673    format!(
674        "{} {} -> HTTP {}\n{}",
675        plan.verb,
676        plan.url,
677        status,
678        serde_json::to_string_pretty(parsed).unwrap_or_else(|_| text.to_owned())
679    )
680}
681
682fn service_hint(method_id: &str) -> &str {
683    method_id.split('.').next().unwrap_or("gmail")
684}
685
686/// Merge `--params` JSON with KEY=VALUE pairs (and, in the generated tree,
687/// with the typed flags). Shared with the generated tree's dispatch: the
688/// later, more specific source always wins, and values that parse as JSON
689/// keep their real type so `--param pageSize=10` is a number.
690pub(crate) fn parse_params(
691    json_blob: Option<&str>,
692    pairs: &[String],
693) -> Result<serde_json::Map<String, Value>> {
694    let mut map = serde_json::Map::new();
695
696    if let Some(blob) = json_blob {
697        let parsed: Value = serde_json::from_str(blob)
698            .with_context(|| "--params must be a JSON object, e.g. '{\"userId\":\"me\"}'")?;
699        let Value::Object(object) = parsed else {
700            bail!("--params must be a JSON object, got {}", type_name(&parsed));
701        };
702        map.extend(object);
703    }
704
705    for pair in pairs {
706        let Some((key, value)) = pair.split_once('=') else {
707            bail!("--param expects KEY=VALUE, got `{pair}`");
708        };
709        // Bare `true`/`123`/`[...]` become real JSON values, so `--param
710        // pageSize=10` is a number and not the string "10" — otherwise
711        // every numeric parameter would need quoting.
712        let coerced = serde_json::from_str::<Value>(value)
713            .unwrap_or_else(|_| Value::String(value.to_owned()));
714        map.insert(key.to_owned(), coerced);
715    }
716
717    Ok(map)
718}
719
720fn type_name(value: &Value) -> &'static str {
721    match value {
722        Value::Null => "null",
723        Value::Bool(_) => "a boolean",
724        Value::Number(_) => "a number",
725        Value::String(_) => "a string",
726        Value::Array(_) => "an array",
727        Value::Object(_) => "an object",
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    #[test]
736    fn json_params_are_merged() {
737        let params = parse_params(Some(r#"{"userId":"me","q":"is:unread"}"#), &[]).unwrap();
738        assert_eq!(params.get("userId").unwrap(), "me");
739        assert_eq!(params.get("q").unwrap(), "is:unread");
740    }
741
742    #[test]
743    fn param_flags_win_over_json_params() {
744        // The later, more specific source should win so a caller can
745        // override one field of a large --params blob.
746        let params =
747            parse_params(Some(r#"{"userId":"me"}"#), &["userId=someone-else".into()]).unwrap();
748        assert_eq!(params.get("userId").unwrap(), "someone-else");
749    }
750
751    #[test]
752    fn param_values_are_coerced_to_json_types() {
753        let params =
754            parse_params(None, &["a=10".into(), "b=true".into(), "c=text".into()]).unwrap();
755        assert_eq!(params.get("a").unwrap(), 10);
756        assert_eq!(params.get("b").unwrap(), true);
757        assert_eq!(params.get("c").unwrap(), "text");
758    }
759
760    #[test]
761    fn values_containing_equals_are_preserved() {
762        // Drive queries and Gmail searches contain '='; only split once.
763        let params = parse_params(None, &["q=name=a.pdf".into()]).unwrap();
764        assert_eq!(params.get("q").unwrap(), "name=a.pdf");
765    }
766
767    #[test]
768    fn malformed_input_is_rejected_with_guidance() {
769        assert!(parse_params(Some("not json"), &[]).is_err());
770        assert!(parse_params(Some("[1,2]"), &[]).is_err());
771        assert!(parse_params(None, &["novalue".into()]).is_err());
772    }
773
774    fn plan(id: &str, params: &[(&str, Value)]) -> (super::RequestPlan, &'static Method) {
775        let (service, method) = crate::discovery::resolve(id).unwrap();
776        let map: serde_json::Map<String, Value> = params
777            .iter()
778            .map(|(k, v)| ((*k).to_owned(), v.clone()))
779            .collect();
780        (
781            super::plan_request(service, method, &map, None, None, &[]).unwrap(),
782            method,
783        )
784    }
785
786    #[test]
787    fn plan_builds_the_url_from_discovery() {
788        let (plan, method) = plan(
789            "gmail.users.messages.list",
790            &[("userId", json!("me")), ("q", json!("is:unread"))],
791        );
792        assert_eq!(plan.verb, "GET");
793        assert_eq!(
794            plan.url,
795            "https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread"
796        );
797        assert_eq!(plan.body, None);
798        assert_eq!(method.id, "users.messages.list");
799    }
800
801    #[test]
802    fn plan_rejects_a_missing_required_parameter_by_name() {
803        let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
804        let err = super::plan_request(service, method, &serde_json::Map::new(), None, None, &[])
805            .unwrap_err()
806            .to_string();
807        assert!(err.contains("missing required parameter `userId`"), "{err}");
808    }
809
810    #[test]
811    fn plan_appends_query_extras_after_discovery_pairs() {
812        let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
813        let mut params = serde_json::Map::new();
814        params.insert("userId".into(), json!("me"));
815        params.insert("q".into(), json!("is:unread"));
816        let plan = super::plan_request(
817            service,
818            method,
819            &params,
820            None,
821            None,
822            &["alt=json".to_owned()],
823        )
824        .unwrap();
825        assert!(plan.url.ends_with("q=is%3Aunread&alt=json"), "{}", plan.url);
826    }
827
828    #[test]
829    fn plan_rejects_malformed_query_extras() {
830        let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
831        let mut params = serde_json::Map::new();
832        params.insert("userId".into(), json!("me"));
833        let err = super::plan_request(
834            service,
835            method,
836            &params,
837            None,
838            None,
839            &["no-equals-sign".to_owned()],
840        )
841        .unwrap_err()
842        .to_string();
843        assert!(err.contains("KEY=VALUE"), "{err}");
844    }
845
846    #[test]
847    fn plan_uppercases_a_verb_override() {
848        let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
849        let mut params = serde_json::Map::new();
850        params.insert("userId".into(), json!("me"));
851        let plan = super::plan_request(service, method, &params, None, Some("post"), &[]).unwrap();
852        assert_eq!(plan.verb, "POST");
853    }
854
855    #[test]
856    fn plan_synthesizes_a_body_for_write_verbs_only() {
857        // drive.files.create is a POST: leftover (non-path) params become
858        // the request body, exactly as `grr api call` always did.
859        let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
860        let mut params = serde_json::Map::new();
861        params.insert("name".into(), json!("quarterly.pdf"));
862        let plan = super::plan_request(service, method, &params, None, None, &[]).unwrap();
863        assert_eq!(plan.body, Some(json!({"name": "quarterly.pdf"})));
864
865        // A GET never gets a body, even with leftover params — they ride
866        // in the query string instead.
867        let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
868        let mut params = serde_json::Map::new();
869        params.insert("userId".into(), json!("me"));
870        params.insert("q".into(), json!("is:unread"));
871        let plan = super::plan_request(service, method, &params, None, None, &[]).unwrap();
872        assert_eq!(plan.body, None);
873        assert!(plan.url.contains("q=is%3Aunread"));
874    }
875
876    #[test]
877    fn plan_parses_json_bodies_and_wraps_the_rest() {
878        // The planner receives already-read bytes (read_body_source owns the
879        // I/O): JSON parses, anything else is wrapped as a string, never
880        // rejected.
881        let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
882        let plan = super::plan_request(
883            service,
884            method,
885            &serde_json::Map::new(),
886            Some(r#"{"name":"uploaded.pdf"}"#),
887            None,
888            &[],
889        )
890        .unwrap();
891        assert_eq!(plan.body, Some(json!({"name": "uploaded.pdf"})));
892
893        let plan = super::plan_request(
894            service,
895            method,
896            &serde_json::Map::new(),
897            Some("not json at all"),
898            None,
899            &[],
900        )
901        .unwrap();
902        assert_eq!(plan.body, Some(json!("not json at all")));
903    }
904
905    #[tokio::test]
906    async fn read_body_source_reads_a_file() {
907        let dir = tempfile::tempdir().unwrap();
908        let path = dir.path().join("body.json");
909        tokio::fs::write(&path, r#"{"a":1}"#).await.unwrap();
910        let raw = super::read_body_source(path.to_str().unwrap())
911            .await
912            .unwrap();
913        assert_eq!(raw, r#"{"a":1}"#);
914    }
915
916    #[test]
917    fn dry_run_payload_is_a_pure_function_of_the_plan() {
918        let (plan, method) = plan("gmail.users.messages.list", &[("userId", json!("me"))]);
919        let payload = super::dry_run_payload(method, &plan);
920        assert_eq!(payload["method"], "GET");
921        assert_eq!(
922            payload["url"],
923            "https://gmail.googleapis.com/gmail/v1/users/me/messages"
924        );
925        assert_eq!(payload["dryRun"], true);
926        assert!(
927            payload["leastPrivilegeScope"]
928                .as_str()
929                .unwrap()
930                .ends_with("gmail.readonly")
931        );
932    }
933
934    fn a_get_plan() -> super::RequestPlan {
935        super::RequestPlan {
936            verb: "GET".into(),
937            url: "https://gmail.googleapis.com/gmail/v1/users/me/messages".into(),
938            body: None,
939        }
940    }
941
942    #[test]
943    fn a_403_names_the_missing_scope_and_keeps_googles_message() {
944        // The scope guidance is the whole point of the 403 branch: without
945        // it a user sees only "HTTP 403" and starts re-running commands
946        // instead of re-running `grr auth login`.
947        let (_, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
948        let parsed = json!({
949            "error": {"code": 403, "message": "Request had insufficient authentication scopes."}
950        });
951        let message = super::api_failure_message(&a_get_plan(), 403, &parsed, "", method);
952        assert!(message.contains("HTTP 403"), "{message}");
953        assert!(
954            message.contains("gmail.readonly"),
955            "the method's least-privilege scope must be named: {message}"
956        );
957        assert!(
958            message.contains("re-run `grr auth login`"),
959            "the remediation must be named: {message}"
960        );
961        assert!(
962            message.contains("insufficient authentication scopes"),
963            "Google's own message must be quoted, not replaced: {message}"
964        );
965    }
966
967    #[test]
968    fn other_statuses_keep_the_verb_url_body_shape() {
969        // The long-standing `VERB url -> HTTP <status>` shape with the
970        // pretty-printed body is an output contract: scripts grep for it.
971        // It also covers the 403-with-no-scope case (API disabled): the
972        // generic body quote is the fallback, never a scope name.
973        let (_, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
974        let parsed = json!({"error": {"code": 404, "message": "Requested entity was not found."}});
975        let message = super::api_failure_message(&a_get_plan(), 404, &parsed, "", method);
976        assert!(
977            message.contains(
978                "GET https://gmail.googleapis.com/gmail/v1/users/me/messages -> HTTP 404"
979            ),
980            "{message}"
981        );
982        assert!(
983            message.contains("Requested entity was not found."),
984            "{message}"
985        );
986    }
987
988    async fn test_auth() -> GoogleAuth {
989        let config = crate::core::config::OAuthConfig {
990            client_id: "test-id.apps.googleusercontent.com".into(),
991            client_secret: None,
992            ..Default::default()
993        };
994        let storage = crate::core::auth::TokenStorage {
995            access_token: "test-token".into(),
996            refresh_token: None,
997            expires_at: u64::MAX,
998            token_type: "Bearer".into(),
999            scope: String::new(),
1000        };
1001        GoogleAuth::with_token(config, storage).await.unwrap()
1002    }
1003
1004    #[tokio::test]
1005    async fn handle_call_refuses_a_write_under_readonly() {
1006        // The gate fires before anything is built or sent, so the dummy
1007        // auth handle is never used.
1008        let auth = test_auth().await;
1009        let args = ApiCallArgs {
1010            method: "gmail.users.messages.send".into(),
1011            params: None,
1012            param: vec![],
1013            body_file: None,
1014            method_override: None,
1015            query: vec![],
1016            dry_run: true,
1017            format: OutputFormat::Json,
1018        };
1019        let err = handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
1020            .await
1021            .unwrap_err()
1022            .to_string();
1023        assert!(err.contains("POST"), "{err}");
1024        assert!(err.contains("--readonly"), "{err}");
1025        assert!(err.contains("Rerun without --readonly"), "{err}");
1026    }
1027
1028    #[tokio::test]
1029    async fn handle_call_refuses_a_denied_service_and_a_denied_verb() {
1030        let auth = test_auth().await;
1031        let args = ApiCallArgs {
1032            method: "gmail.users.messages.list".into(),
1033            params: None,
1034            param: vec![],
1035            body_file: None,
1036            method_override: None,
1037            query: vec![],
1038            dry_run: true,
1039            format: OutputFormat::Json,
1040        };
1041        let profile = SafetyProfile::new(false, vec!["gmail".to_owned()], vec![]);
1042        let err = handle_call(Some(&auth), args, &profile)
1043            .await
1044            .unwrap_err()
1045            .to_string();
1046        assert!(err.contains("--deny-service gmail"), "{err}");
1047
1048        let args = ApiCallArgs {
1049            method: "gmail.users.messages.list".into(),
1050            params: None,
1051            param: vec![],
1052            body_file: None,
1053            method_override: None,
1054            query: vec![],
1055            dry_run: true,
1056            format: OutputFormat::Json,
1057        };
1058        let profile = SafetyProfile::new(false, vec![], vec!["get".to_owned()]);
1059        let err = handle_call(Some(&auth), args, &profile)
1060            .await
1061            .unwrap_err()
1062            .to_string();
1063        assert!(err.contains("--deny-verb GET"), "{err}");
1064    }
1065
1066    #[tokio::test]
1067    async fn handle_call_runs_a_read_under_readonly() {
1068        let auth = test_auth().await;
1069        let args = ApiCallArgs {
1070            method: "gmail.users.messages.list".into(),
1071            params: None,
1072            param: vec!["userId=me".into()],
1073            body_file: None,
1074            method_override: None,
1075            query: vec![],
1076            dry_run: true,
1077            format: OutputFormat::Json,
1078        };
1079        // A GET is not a write: the dry-run returns instead of refusing.
1080        assert!(
1081            handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
1082                .await
1083                .is_ok()
1084        );
1085    }
1086}