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