Skip to main content

grr_cli/
discovery.rs

1//! Discovery-driven API surface.
2//!
3//! Google's Discovery Service publishes a machine-readable description of
4//! every Workspace method: HTTP verb, path template, parameters and OAuth
5//! scopes. `scripts/fetch-discovery.mjs` distils those documents into a
6//! compact index (method id, verb, path, scopes, parameters) which this
7//! module embeds — 308 methods across ten services in ~350 KiB, versus
8//! several megabytes for the raw documents.
9//!
10//! This is the escape hatch that makes the curated commands unnecessary for
11//! coverage: `grr api call <service>.<resource>.<method>` reaches every
12//! method Google publishes, including ones that did not exist when grr was
13//! written, with no code change and no release.
14//!
15//! Two properties matter more than the coverage:
16//!
17//! 1. **Least privilege.** Each method carries its own scope list, and grr
18//!    authorises only the scopes the requested method needs. Asking for all
19//!    97 Workspace scopes up front would exceed the ~25-scope ceiling
20//!    Google applies to unverified apps in testing mode, and would fail at
21//!    consent; per-method authorisation sidesteps that entirely.
22//! 2. **Offline.** The index is compiled in, so `grr api list` and
23//!    `describe` work with no network and no cache directory.
24
25use serde::Deserialize;
26use std::collections::BTreeMap;
27use std::sync::OnceLock;
28
29/// `(filename, service name, api version, discovery url)` — the version is
30/// what callers need for display; the url is what `grr api refresh` fetches.
31const SERVICES: &[(&str, &str, &str, &str)] = &[
32    (
33        "gmail.json",
34        "gmail",
35        "v1",
36        "https://gmail.googleapis.com/$discovery/rest?version=v1",
37    ),
38    (
39        "calendar.json",
40        "calendar",
41        "v3",
42        "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
43    ),
44    (
45        "drive.json",
46        "drive",
47        "v3",
48        "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
49    ),
50    (
51        "people.json",
52        "people",
53        "v1",
54        "https://people.googleapis.com/$discovery/rest?version=v1",
55    ),
56    (
57        "chat.json",
58        "chat",
59        "v1",
60        "https://chat.googleapis.com/$discovery/rest?version=v1",
61    ),
62    (
63        "forms.json",
64        "forms",
65        "v1",
66        "https://forms.googleapis.com/$discovery/rest?version=v1",
67    ),
68    (
69        "tasks.json",
70        "tasks",
71        "v1",
72        "https://tasks.googleapis.com/$discovery/rest?version=v1",
73    ),
74    (
75        "docs.json",
76        "docs",
77        "v1",
78        "https://docs.googleapis.com/$discovery/rest?version=v1",
79    ),
80    (
81        "sheets.json",
82        "sheets",
83        "v4",
84        "https://sheets.googleapis.com/$discovery/rest?version=v4",
85    ),
86    (
87        "slides.json",
88        "slides",
89        "v1",
90        "https://slides.googleapis.com/$discovery/rest?version=v1",
91    ),
92    (
93        "script.json",
94        "script",
95        "v1",
96        "https://script.googleapis.com/$discovery/rest?version=v1",
97    ),
98    (
99        "analyticsadmin.json",
100        "analyticsadmin",
101        "v1beta",
102        "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta",
103    ),
104    (
105        "analyticsdata.json",
106        "analyticsdata",
107        "v1beta",
108        "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta",
109    ),
110    (
111        "searchconsole.json",
112        "searchconsole",
113        "v1",
114        "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
115    ),
116];
117
118#[derive(Debug, Clone, serde::Serialize, Deserialize)]
119pub struct Parameter {
120    pub name: String,
121    #[serde(rename = "type")]
122    pub kind: String,
123    pub required: bool,
124    #[serde(default)]
125    pub repeated: bool,
126    #[serde(default)]
127    pub location: Option<String>,
128    #[serde(default)]
129    pub description: String,
130    #[serde(default, rename = "enum")]
131    pub enum_values: Option<Vec<String>>,
132}
133
134#[derive(Debug, Clone, serde::Serialize, Deserialize)]
135pub struct Method {
136    pub id: String,
137    #[serde(rename = "httpMethod")]
138    pub http_method: String,
139    pub path: String,
140    #[serde(default)]
141    pub flat_path: Option<String>,
142    #[serde(default)]
143    pub description: String,
144    #[serde(default)]
145    pub scopes: Vec<String>,
146    #[serde(default)]
147    pub required: Vec<String>,
148    #[serde(default)]
149    pub parameters: Vec<Parameter>,
150}
151
152impl Method {
153    /// Narrowest scope this method accepts, or `None` for the public
154    /// methods that need no authorisation at all.
155    ///
156    /// Discovery lists scopes in descending breadth
157    /// (`https://mail.google.com/` first), and the trailing entries are the
158    /// narrow `.../auth/gmail.readonly` style. Preferring the last one keeps
159    /// a `describe` call from requesting full mailbox access.
160    pub fn least_privilege_scope(&self) -> Option<&str> {
161        self.scopes
162            .iter()
163            .rev()
164            .find(|s| s.starts_with("https://www.googleapis.com/auth/"))
165            .map(String::as_str)
166            .or_else(|| self.scopes.first().map(String::as_str))
167    }
168
169    pub fn parameter(&self, name: &str) -> Option<&Parameter> {
170        self.parameters.iter().find(|p| p.name == name)
171    }
172
173    /// Placeholders in the path template, e.g. `{userId}` in
174    /// `gmail/v1/users/{userId}/messages`.
175    ///
176    /// RFC 6570 reserved-expansion markers are stripped: `{+name}` still
177    /// refers to the parameter named `name` (Chat, People and Slides write
178    /// their resource-name paths that way), so the returned names line up
179    /// with Discovery's parameter list.
180    pub fn path_placeholders(&self) -> Vec<&str> {
181        let mut out = Vec::new();
182        let mut rest = self.path.as_str();
183        while let Some(start) = rest.find('{') {
184            let Some(end) = rest[start..].find('}') else {
185                break;
186            };
187            let mut name = &rest[start + 1..start + end];
188            if name.starts_with('+') {
189                name = &name[1..];
190            }
191            out.push(name);
192            rest = &rest[start + end + 1..];
193        }
194        out
195    }
196
197    /// The subset of [`Method::path_placeholders`] declared with RFC 6570
198    /// reserved expansion (`{+name}`): their values are full resource names
199    /// (`spaces/AAA/messages/BBB`) that must keep their slashes in the path.
200    pub fn reserved_path_placeholders(&self) -> Vec<&str> {
201        let mut out = Vec::new();
202        let mut rest = self.path.as_str();
203        while let Some(start) = rest.find('{') {
204            let Some(end) = rest[start..].find('}') else {
205                break;
206            };
207            let name = &rest[start + 1..start + end];
208            if let Some(stripped) = name.strip_prefix('+') {
209                out.push(stripped);
210            }
211            rest = &rest[start + end + 1..];
212        }
213        out
214    }
215}
216
217#[derive(Debug, Clone, serde::Serialize, Deserialize)]
218pub struct Service {
219    pub name: String,
220    pub version: String,
221    #[serde(default)]
222    pub revision: Option<String>,
223    #[serde(default)]
224    pub title: String,
225    #[serde(default, rename = "rootUrl")]
226    pub root_url: Option<String>,
227    #[serde(default, rename = "servicePath")]
228    pub service_path: String,
229    #[serde(default, rename = "basePath")]
230    pub base_path: String,
231    #[serde(default, rename = "batchPath")]
232    pub batch_path: Option<String>,
233    pub methods: Vec<Method>,
234}
235
236impl Service {
237    pub fn method(&self, id: &str) -> Option<&Method> {
238        self.methods.iter().find(|m| m.id == id)
239    }
240
241    /// Path prefix shared by every method, normalised to have **no** leading
242    /// slash.
243    ///
244    /// Discovery is inconsistent here: Gmail's `basePath` is `gmail/v1/`
245    /// while Drive's is `/drive/v3/`. Since every `rootUrl` ends in `/`,
246    /// concatenating naively yields `https://www.googleapis.com//drive/v3/...`
247    /// — a double slash that Google answers with a 404 rather than a
248    /// redirect. Strip it here, once, instead of at every call site.
249    pub fn base(&self) -> String {
250        let raw = if self.base_path.is_empty() {
251            self.service_path.as_str()
252        } else {
253            self.base_path.as_str()
254        };
255        raw.trim_start_matches('/').to_owned()
256    }
257}
258
259static SERVICES_CACHE: OnceLock<BTreeMap<&'static str, Service>> = OnceLock::new();
260
261/// Where `grr api refresh` stores fetched indexes, and where `services()`
262/// looks for a copy fresher than the embedded baseline.
263///
264/// `~/.grr/discovery/` — next to the config file, so uninstalling grr's
265/// data directory removes it too.
266pub fn cache_dir() -> Option<std::path::PathBuf> {
267    dirs::home_dir().map(|home| home.join(".grr").join("discovery"))
268}
269
270/// Google marks revisions with date-like strings (`20260923`), so a numeric
271/// comparison is exact; an unparseable revision ranks as zero, which makes a
272/// corrupt cache lose to any embedded baseline rather than win.
273fn revision_rank(revision: &str) -> u64 {
274    revision.trim().parse::<u64>().unwrap_or(0)
275}
276
277/// Every service, keyed by name: the embedded baseline overlaid with any
278/// fresher on-disk cache. Parsed once, on first use.
279///
280/// This is the "best of both worlds": the embedded index keeps `grr api`
281/// instant and offline-capable, while a cache written by `grr api refresh`
282/// (or by a nightly CI run that shipped in a newer release) is preferred
283/// whenever its revision is newer. No network I/O happens here — implicit
284/// use must never block or fail on connectivity — so a user who never
285/// refreshes still gets the compile-time baseline.
286pub fn services() -> &'static BTreeMap<&'static str, Service> {
287    SERVICES_CACHE.get_or_init(|| {
288        let mut map: BTreeMap<&'static str, Service> = BTreeMap::new();
289        for (file, name, version, _url) in SERVICES {
290            // Every SERVICES entry needs its own arm: the wildcard below is
291            // a tripwire, not a fallback — a new service added to SERVICES
292            // without an arm would otherwise silently load the LAST file's
293            // content (four services once presented as Google Slides).
294            let body = match *file {
295                "gmail.json" => include_str!("discovery/gmail.json"),
296                "calendar.json" => include_str!("discovery/calendar.json"),
297                "drive.json" => include_str!("discovery/drive.json"),
298                "people.json" => include_str!("discovery/people.json"),
299                "chat.json" => include_str!("discovery/chat.json"),
300                "forms.json" => include_str!("discovery/forms.json"),
301                "tasks.json" => include_str!("discovery/tasks.json"),
302                "docs.json" => include_str!("discovery/docs.json"),
303                "sheets.json" => include_str!("discovery/sheets.json"),
304                "slides.json" => include_str!("discovery/slides.json"),
305                "script.json" => include_str!("discovery/script.json"),
306                "analyticsadmin.json" => include_str!("discovery/analyticsadmin.json"),
307                "analyticsdata.json" => include_str!("discovery/analyticsdata.json"),
308                "searchconsole.json" => include_str!("discovery/searchconsole.json"),
309                _ => panic!("no embedded discovery index for {file}; add an include_str! arm"),
310            };
311            let embedded = match serde_json::from_str::<Service>(body) {
312                Ok(mut service) => {
313                    // The filename is the source of truth for the name and
314                    // version; trust it over whatever the document claimed so
315                    // a mislabelled upstream doc cannot desync the two.
316                    service.name = (*name).to_owned();
317                    service.version = (*version).to_owned();
318                    service
319                }
320                Err(error) => {
321                    // A malformed embedded index is a build defect, not a
322                    // runtime condition: fail loudly rather than silently
323                    // dropping a service.
324                    panic!("embedded discovery index {file} failed to parse: {error}");
325                }
326            };
327
328            let fresher = cache_dir().and_then(|dir| {
329                let path = dir.join(format!("{name}.json"));
330                let Ok(body) = std::fs::read_to_string(&path) else {
331                    return None;
332                };
333                match serde_json::from_str::<Service>(&body) {
334                    // A corrupt cache must never take the index down: fall
335                    // back to the embedded baseline and say so once.
336                    Ok(cached)
337                        if revision_rank(cached.revision.as_deref().unwrap_or(""))
338                            > revision_rank(embedded.revision.as_deref().unwrap_or("")) =>
339                    {
340                        Some(cached)
341                    }
342                    Ok(_) => None,
343                    Err(error) => {
344                        tracing::warn!(
345                            "discovery cache {} is corrupt ({error}); using the embedded index",
346                            path.display()
347                        );
348                        None
349                    }
350                }
351            });
352
353            map.insert(name, fresher.unwrap_or(embedded));
354        }
355        map
356    })
357}
358
359/// Resolve `service.resource.method` (or `service..method` for the few
360/// top-level methods that have no resource).
361pub fn resolve(id: &str) -> Result<(&'static Service, &'static Method), String> {
362    let id = id.trim().trim_start_matches('.').replace('/', ".");
363    let (service_name, method_id) = id.split_once('.').ok_or_else(|| {
364        format!("`{id}` is not a method id; expected <service>.<resource>.<method>")
365    })?;
366    let service = services().get(service_name).ok_or_else(|| {
367        let known: Vec<&str> = services().keys().copied().collect();
368        format!(
369            "unknown service `{service_name}`; known: {}",
370            known.join(", ")
371        )
372    })?;
373    let method = service.method(method_id).ok_or_else(|| {
374        let mut ids: Vec<&str> = service.methods.iter().map(|m| m.id.as_str()).collect();
375        ids.sort_unstable();
376        // A near-miss is nearly always a typo in the *method* name, not the
377        // resource path, so match on the last segment. Ranking matters:
378        // `users.messages.list` and `users.drafts.list` are both one edit
379        // from `lst`, and only the resource path tells them apart.
380        let wanted = method_id.rsplit('.').next().unwrap_or(method_id);
381        let mut scored: Vec<(usize, std::cmp::Reverse<usize>, &str)> = ids
382            .iter()
383            .copied()
384            .filter_map(|candidate| {
385                let actual = candidate.rsplit('.').next().unwrap_or(candidate);
386                let bucket = if actual == wanted {
387                    0
388                } else if actual.starts_with(wanted) || wanted.starts_with(actual) {
389                    1
390                } else if edit_distance_at_most(actual, wanted, 1) {
391                    // Limit 1, not 2: `lst` is one edit from `list` but two
392                    // from `get`, and admitting distance 2 turns every short
393                    // method name into a candidate.
394                    2
395                } else {
396                    return None;
397                };
398                // Prefer the candidate sharing the most of the resource path
399                // (Reverse: longer shared prefix sorts first).
400                Some((
401                    bucket,
402                    std::cmp::Reverse(shared_prefix(candidate, method_id)),
403                    candidate,
404                ))
405            })
406            .collect();
407        scored.sort();
408        let mut near: Vec<&str> = scored
409            .into_iter()
410            .map(|(_, _, candidate)| candidate)
411            .collect();
412        near.truncate(5);
413        if near.is_empty() {
414            format!(
415                "unknown method `{method_id}` in `{service_name}` ({} methods available)",
416                service.methods.len()
417            )
418        } else {
419            format!(
420                "unknown method `{method_id}`; did you mean: {}",
421                near.join(", ")
422            )
423        }
424    })?;
425    Ok((service, method))
426}
427
428/// Length of the longest common prefix.
429fn shared_prefix(left: &str, right: &str) -> usize {
430    left.chars()
431        .zip(right.chars())
432        .take_while(|(a, b)| a == b)
433        .count()
434}
435
436/// Distil a raw Google Discovery document into the compact index this module
437/// embeds.
438///
439/// This is the Rust twin of `collect()` in `scripts/fetch-discovery.mjs`,
440/// which produces the committed baseline. Both must follow the same rules or
441/// a refreshed cache would disagree with the embedded index in shape. The
442/// rules that matter:
443///   - walk `resources` ENTRIES (not values): the resource name prefixes the
444///     method id;
445///   - descriptions are whitespace-collapsed and truncated (the raw Gmail doc
446///     alone is ~1.5 MB);
447///   - required parameters are the ones `parameterOrder` names;
448///   - parameters sort by name, methods sort by id — determinism keeps the
449///     nightly refresh diff quiet.
450pub(crate) fn distill(doc: &serde_json::Value) -> Result<Vec<Method>, String> {
451    let mut out = Vec::new();
452    let resources = doc
453        .get("resources")
454        .and_then(|r| r.as_object())
455        .ok_or_else(|| "document has no resources object".to_owned())?;
456
457    fn walk(node: &serde_json::Value, path: &str, out: &mut Vec<Method>) {
458        let Some(object) = node.as_object() else {
459            return;
460        };
461        if let Some(methods) = object.get("methods").and_then(|m| m.as_object()) {
462            for (name, method) in methods {
463                let id = if path.is_empty() {
464                    name.clone()
465                } else {
466                    format!("{path}.{name}")
467                };
468                let http_method = method
469                    .get("httpMethod")
470                    .and_then(|v| v.as_str())
471                    .unwrap_or("GET")
472                    .to_owned();
473                let path_template = method
474                    .get("path")
475                    .and_then(|v| v.as_str())
476                    .unwrap_or("")
477                    .to_owned();
478                let flat_path = method
479                    .get("flatPath")
480                    .and_then(|v| v.as_str())
481                    .map(str::to_owned);
482                let description = method
483                    .get("description")
484                    .and_then(|v| v.as_str())
485                    .unwrap_or("")
486                    .split_whitespace()
487                    .collect::<Vec<_>>()
488                    .join(" ");
489                let description = description.chars().take(300).collect::<String>();
490                let scopes = method
491                    .get("scopes")
492                    .and_then(|v| v.as_array())
493                    .map(|a| {
494                        a.iter()
495                            .filter_map(|s| s.as_str().map(str::to_owned))
496                            .collect::<Vec<String>>()
497                    })
498                    .unwrap_or_default();
499                let required = method
500                    .get("parameterOrder")
501                    .and_then(|v| v.as_array())
502                    .map(|a| {
503                        a.iter()
504                            .filter_map(|s| s.as_str().map(str::to_owned))
505                            .collect::<Vec<String>>()
506                    })
507                    .unwrap_or_default();
508
509                let mut parameters: Vec<Parameter> = method
510                    .get("parameters")
511                    .and_then(|p| p.as_object())
512                    .map(|params| {
513                        params
514                            .iter()
515                            .map(|(pname, p)| Parameter {
516                                name: pname.clone(),
517                                kind: p
518                                    .get("type")
519                                    .and_then(|v| v.as_str())
520                                    .unwrap_or("string")
521                                    .to_owned(),
522                                required: required.iter().any(|r| r == pname),
523                                repeated: p
524                                    .get("repeated")
525                                    .and_then(|v| v.as_bool())
526                                    .unwrap_or(false),
527                                location: p
528                                    .get("location")
529                                    .and_then(|v| v.as_str())
530                                    .map(str::to_owned),
531                                description: {
532                                    let text = p
533                                        .get("description")
534                                        .and_then(|v| v.as_str())
535                                        .unwrap_or("")
536                                        .split_whitespace()
537                                        .collect::<Vec<_>>()
538                                        .join(" ");
539                                    text.chars().take(160).collect::<String>()
540                                },
541                                enum_values: p.get("enum").and_then(|v| v.as_array()).map(|a| {
542                                    a.iter()
543                                        .filter_map(|s| s.as_str().map(str::to_owned))
544                                        .collect()
545                                }),
546                            })
547                            .collect()
548                    })
549                    .unwrap_or_default();
550                parameters.sort_by(|a, b| a.name.cmp(&b.name));
551
552                out.push(Method {
553                    id,
554                    http_method,
555                    path: path_template,
556                    flat_path,
557                    description,
558                    scopes,
559                    required,
560                    parameters,
561                });
562            }
563        }
564        if let Some(nested) = object.get("resources").and_then(|r| r.as_object()) {
565            for (rname, resource) in nested {
566                let child = if path.is_empty() {
567                    rname.clone()
568                } else {
569                    format!("{path}.{rname}")
570                };
571                walk(resource, &child, out);
572            }
573        }
574    }
575
576    for (name, resource) in resources {
577        walk(resource, name, &mut out);
578    }
579    out.sort_by(|a, b| a.id.cmp(&b.id));
580    Ok(out)
581}
582
583/// Fetch fresh Discovery documents and write them to the cache directory.
584///
585/// The explicit, network path — `grr api refresh`. Everything else in this
586/// module works offline off the embedded baseline; this is how a user (or a
587/// nightly CI run) pulls the index forward between releases. On any failure
588/// for one service, the embedded baseline simply stays in place for it: a
589/// refresh must never leave the index WORSE than it was.
590pub async fn refresh(filter: Option<&str>) -> Result<Vec<RefreshReport>, String> {
591    use crate::core::http::build_http_client;
592
593    let client = build_http_client().map_err(|e| e.to_string())?;
594    let Some(dir) = cache_dir() else {
595        return Err("could not find your home directory".to_owned());
596    };
597    tokio::fs::create_dir_all(&dir)
598        .await
599        .map_err(|e| e.to_string())?;
600
601    // The services are independent: every fetch runs as its own task instead
602    // of serialising 14 round-trips. Reports are re-sorted into SERVICES
603    // order below, so the CLI output stays deterministic regardless of
604    // completion order.
605    let mut tasks = tokio::task::JoinSet::new();
606    let mut wanted = 0usize;
607    for (file, name, version, url) in SERVICES {
608        if let Some(wanted_name) = filter
609            && !name.eq_ignore_ascii_case(wanted_name)
610        {
611            continue;
612        }
613        wanted += 1;
614        let client = client.clone();
615        let dir = dir.clone();
616        tasks.spawn(async move { fetch_one(&client, file, name, version, url, &dir).await });
617    }
618    if wanted == 0 {
619        return Ok(Vec::new());
620    }
621
622    let mut reports = Vec::with_capacity(wanted);
623    while let Some(joined) = tasks.join_next().await {
624        match joined {
625            Ok(Ok(report)) => reports.push(report),
626            Ok(Err(e)) => {
627                // Preserve the historical all-or-nothing contract: a failed
628                // service aborts the refresh (the serial loop's `?` used to
629                // propagate it) and the remaining tasks are told to stop.
630                tasks.abort_all();
631                return Err(e);
632            }
633            Err(join_error) => {
634                tasks.abort_all();
635                return Err(format!("refresh task failed: {join_error}"));
636            }
637        }
638    }
639    reports.sort_by_key(|report| {
640        SERVICES
641            .iter()
642            .position(|(_, name, _, _)| *name == report.service)
643            .unwrap_or(usize::MAX)
644    });
645    Ok(reports)
646}
647
648/// Fetch, distil and cache one service. Split out of [`refresh`] so all the
649/// services can be in flight at once.
650async fn fetch_one(
651    client: &reqwest::Client,
652    file: &str,
653    name: &str,
654    version: &str,
655    url: &str,
656    dir: &std::path::Path,
657) -> Result<RefreshReport, String> {
658    let body = client
659        .get(url::Url::parse(url).map_err(|e| format!("{name}: bad discovery url: {e}"))?)
660        .send()
661        .await
662        .map_err(|e| format!("{name}: {e}"))?
663        .text()
664        .await
665        .map_err(|e| format!("{name}: {e}"))?;
666
667    let doc: serde_json::Value = serde_json::from_str(&body)
668        .map_err(|e| format!("{name}: discovery response is not JSON: {e}"))?;
669    let revision = doc
670        .get("revision")
671        .and_then(|v| v.as_str())
672        .unwrap_or("")
673        .to_owned();
674
675    // The embedded revision for this service, for the comparison report.
676    let embedded_revision = services()
677        .get(name)
678        .and_then(|s| s.revision.clone())
679        .unwrap_or_default();
680
681    // A stale or failed refresh must not take the index down: only write
682    // the cache when the fetched document distils cleanly AND is not
683    // older than what is already in use.
684    let methods = distill(&doc)?;
685    if revision_rank(&revision) < revision_rank(&embedded_revision) {
686        return Ok(RefreshReport {
687            service: name.to_owned(),
688            from: embedded_revision,
689            to: revision,
690            methods: methods.len(),
691            wrote_cache: false,
692            skipped_older: true,
693        });
694    }
695
696    let index = Service {
697        name: name.to_owned(),
698        version: version.to_owned(),
699        revision: Some(revision.clone()),
700        title: doc
701            .get("title")
702            .and_then(|v| v.as_str())
703            .unwrap_or(name)
704            .to_owned(),
705        root_url: doc
706            .get("rootUrl")
707            .and_then(|v| v.as_str())
708            .map(str::to_owned),
709        service_path: doc
710            .get("servicePath")
711            .and_then(|v| v.as_str())
712            .unwrap_or("")
713            .to_owned(),
714        base_path: doc
715            .get("basePath")
716            .and_then(|v| v.as_str())
717            .unwrap_or("")
718            .to_owned(),
719        batch_path: doc
720            .get("batchPath")
721            .and_then(|v| v.as_str())
722            .map(str::to_owned),
723        methods,
724    };
725    let json = serde_json::to_string(&index).map_err(|e| e.to_string())?;
726    let path = dir.join(file);
727    tokio::fs::write(&path, json)
728        .await
729        .map_err(|e| format!("{}: {e}", path.display()))?;
730
731    Ok(RefreshReport {
732        service: name.to_owned(),
733        from: embedded_revision,
734        to: revision,
735        methods: index.methods.len(),
736        wrote_cache: true,
737        skipped_older: false,
738    })
739}
740
741/// One service's refresh outcome, for the `grr api refresh` report.
742#[derive(Debug, Clone, serde::Serialize)]
743pub struct RefreshReport {
744    pub service: String,
745    pub from: String,
746    pub to: String,
747    pub methods: usize,
748    pub wrote_cache: bool,
749    pub skipped_older: bool,
750}
751
752/// Levenshtein distance, abandoned as soon as it exceeds `limit`.
753///
754/// Method names are short, so the quadratic table is free; the early exit
755/// keeps the whole 308-method scan cheap.
756fn edit_distance_at_most(left: &str, right: &str, limit: usize) -> bool {
757    let a: Vec<char> = left.chars().collect();
758    let b: Vec<char> = right.chars().collect();
759    if a.len().abs_diff(b.len()) > limit {
760        return false;
761    }
762    let mut previous: Vec<usize> = (0..=b.len()).collect();
763    let mut current = vec![0usize; b.len() + 1];
764    for i in 1..=a.len() {
765        current[0] = i;
766        let mut row_best = current[0];
767        for j in 1..=b.len() {
768            let cost = usize::from(a[i - 1] != b[j - 1]);
769            current[j] = (previous[j] + 1)
770                .min(current[j - 1] + 1)
771                .min(previous[j - 1] + cost);
772            row_best = row_best.min(current[j]);
773        }
774        if row_best > limit {
775            return false;
776        }
777        std::mem::swap(&mut previous, &mut current);
778    }
779    previous[b.len()] <= limit
780}
781
782/// Expand a discovery path template into a URL path, substituting `{...}`
783/// placeholders from the supplied parameters.
784///
785/// Returns an error naming the missing placeholder rather than sending a
786/// request with a literal `{userId}` in it — which Google answers with an
787/// opaque 404.
788///
789/// `{+name}` placeholders (RFC 6570 reserved expansion) keep their value's
790/// slashes and other path characters, per the expansion's meaning: a Chat
791/// space name like `spaces/AAA` must remain a two-segment path, not a
792/// percent-encoded blob.
793pub fn build_path(
794    service: &Service,
795    method: &Method,
796    params: &serde_json::Map<String, serde_json::Value>,
797) -> Result<String, String> {
798    let reserved = method.reserved_path_placeholders();
799    let mut path = method.path.clone();
800    for placeholder in method.path_placeholders() {
801        let Some(value) = params.get(placeholder) else {
802            let known: Vec<&str> = method
803                .parameters
804                .iter()
805                .filter(|p| p.required)
806                .map(|p| p.name.as_str())
807                .collect();
808            return Err(format!(
809                "`{}` needs the `{placeholder}` parameter (path). Required: {}",
810                method.id,
811                if known.is_empty() {
812                    "none".to_owned()
813                } else {
814                    known.join(", ")
815                }
816            ));
817        };
818        // Path values must be percent-encoded: ids legitimately contain
819        // spaces, slashes and unicode (Drive file names, Gmail labels).
820        // Reserved-expansion values are the documented exception — their
821        // `/` separators ARE the path structure.
822        let text = value_to_string(value);
823        let encoded = if reserved.contains(&placeholder) {
824            encode_path_reserved(&text)
825        } else {
826            urlencoding::encode(&text).into_owned()
827        };
828        path = path
829            .replace(&format!("{{{placeholder}}}"), &encoded)
830            .replace(&format!("{{+{placeholder}}}"), &encoded);
831    }
832    Ok(format!(
833        "{}{}",
834        service.base(),
835        path.trim_start_matches('/')
836    ))
837}
838
839/// Percent-encode a path value for RFC 6570 reserved expansion: keep every
840/// `pchar` (path character) — most importantly `/`, so Google's full
841/// resource names (`spaces/AAA/messages/BBB`) stay a real path — and
842/// encode the rest (spaces, `?`, `#`, `%`, non-ASCII, …).
843fn encode_path_reserved(value: &str) -> String {
844    let mut out = String::with_capacity(value.len());
845    for byte in value.bytes() {
846        match byte {
847            b'A'..=b'Z'
848            | b'a'..=b'z'
849            | b'0'..=b'9'
850            | b'-'
851            | b'.'
852            | b'_'
853            | b'~'
854            | b'/'
855            | b':'
856            | b'@'
857            | b'!'
858            | b'$'
859            | b'&'
860            | b'\''
861            | b'('
862            | b')'
863            | b'*'
864            | b'+'
865            | b','
866            | b';'
867            | b'=' => {
868                out.push(byte as char);
869            }
870            _ => {
871                // Hand-rolled hex instead of format!(): this runs for every
872                // non-pchar byte of every encoded path value.
873                const HEX: &[u8; 16] = b"0123456789ABCDEF";
874                out.push('%');
875                out.push(HEX[(byte >> 4) as usize] as char);
876                out.push(HEX[(byte & 0x0F) as usize] as char);
877            }
878        }
879    }
880    out
881}
882
883pub fn value_to_string(value: &serde_json::Value) -> String {
884    match value {
885        serde_json::Value::String(s) => s.clone(),
886        serde_json::Value::Null => String::new(),
887        other => other.to_string(),
888    }
889}
890
891/// Render a parameter map as a query string, skipping path parameters
892/// (already substituted) and honouring `repeated: true` for multi-valued
893/// params such as Gmail's `labelIds`.
894pub fn build_query(method: &Method, params: &serde_json::Map<String, serde_json::Value>) -> String {
895    let placeholders = method.path_placeholders();
896    let mut pairs: Vec<(String, String)> = Vec::new();
897    for (key, value) in params {
898        if placeholders.contains(&key.as_str()) {
899            continue;
900        }
901        if let Some(param) = method.parameter(key)
902            && param.location.as_deref() == Some("path")
903        {
904            continue;
905        }
906        match value {
907            serde_json::Value::Array(items) => {
908                for item in items {
909                    pairs.push((key.clone(), value_to_string(item)));
910                }
911            }
912            serde_json::Value::Null => {}
913            other => pairs.push((key.clone(), value_to_string(other))),
914        }
915    }
916    if pairs.is_empty() {
917        return String::new();
918    }
919    pairs.sort();
920    // One pre-sized buffer instead of N format! strings plus a Vec plus
921    // join's result — this runs per request URL.
922    let mut query = String::with_capacity(pairs.len() * 16);
923    for (k, v) in &pairs {
924        if !query.is_empty() {
925            query.push('&');
926        }
927        query.push_str(&urlencoding::encode(k));
928        query.push('=');
929        query.push_str(&urlencoding::encode(v));
930    }
931    format!("?{query}")
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    #[test]
939    fn every_service_parses_and_has_methods() {
940        let all = services();
941        assert_eq!(all.len(), SERVICES.len());
942        for (name, service) in all {
943            assert!(!service.methods.is_empty(), "{name} has no methods");
944        }
945    }
946
947    #[test]
948    fn the_index_covers_the_whole_workspace_surface() {
949        // The point of the dynamic path is coverage. If Google's docs change
950        // shape and we silently parse zero methods, this is what catches it.
951        let total: usize = services().values().map(|s| s.methods.len()).sum();
952        assert!(
953            total >= 300,
954            "expected 300+ methods across the Workspace APIs, found {total}"
955        );
956    }
957
958    #[test]
959    fn every_service_embeds_its_own_index_not_its_neighbors() {
960        // The include_str! match arms must cover every SERVICES entry. The
961        // wildcard once mapped the four newer services onto the Slides
962        // content — inflating the total and only surfacing when `grr ask`
963        // derived per-service rubrics. Distinct method-id sets catch it.
964        let all = services();
965        let distinct: std::collections::BTreeSet<String> = all
966            .values()
967            .map(|s| {
968                s.methods
969                    .iter()
970                    .map(|m| m.id.as_str())
971                    .collect::<Vec<_>>()
972                    .join(",")
973            })
974            .collect();
975        assert_eq!(
976            distinct.len(),
977            all.len(),
978            "two services share a method-id set — an include_str! arm is missing"
979        );
980    }
981
982    #[test]
983    fn resolves_a_nested_method_id() {
984        let (service, method) =
985            resolve("gmail.users.messages.list").expect("gmail.users.messages.list");
986        assert_eq!(service.name, "gmail");
987        assert_eq!(method.http_method, "GET");
988        assert_eq!(method.path, "gmail/v1/users/{userId}/messages");
989    }
990
991    #[test]
992    fn resolves_a_method_without_an_intermediate_resource() {
993        // `users.getProfile` sits directly on the users resource, so there is
994        // only one dot before the method name.
995        let (service, method) = resolve("gmail.users.getProfile").expect("gmail.users.getProfile");
996        assert_eq!(service.name, "gmail");
997        assert_eq!(method.http_method, "GET");
998    }
999
1000    #[test]
1001    fn accepts_a_leading_dot_and_slashes() {
1002        assert!(resolve(".gmail.users.messages.list").is_ok());
1003        assert!(resolve("gmail/users/messages/list").is_ok());
1004    }
1005
1006    #[test]
1007    fn unknown_service_and_method_are_actionable() {
1008        // A typo close to a real method gets a concrete suggestion.
1009        let err = resolve("gmail.users.messages.lst").unwrap_err();
1010        assert!(err.contains("did you mean"), "unhelpful: {err}");
1011        let err = resolve("nope.thing.method").unwrap_err();
1012        assert!(err.contains("known:"), "should list services: {err}");
1013        let err = resolve("gmailonly").unwrap_err();
1014        assert!(err.contains("expected <service>"), "unhelpful: {err}");
1015    }
1016
1017    #[test]
1018    fn least_privilege_scope_prefers_the_narrow_one() {
1019        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1020        // gmail.users.messages.list accepts full-mailbox access first in the
1021        // discovery list; we must not pick that.
1022        let scope = method.least_privilege_scope().expect("a scope");
1023        assert!(scope.ends_with("gmail.readonly"), "picked {scope}");
1024    }
1025
1026    #[test]
1027    fn required_parameters_are_flagged() {
1028        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1029        let user_id = method.parameter("userId").expect("userId param");
1030        assert!(user_id.required);
1031        let max = method.parameter("maxResults").expect("maxResults param");
1032        assert!(!max.required);
1033    }
1034
1035    #[test]
1036    fn builds_a_path_and_reports_missing_placeholders() {
1037        let (service, method) = resolve("gmail.users.messages.get").unwrap();
1038        let mut params = serde_json::Map::new();
1039        params.insert("id".into(), serde_json::json!("m1"));
1040
1041        // A missing path parameter must name itself rather than produce a
1042        // request with a literal brace in the URL.
1043        let err = build_path(service, method, &params).unwrap_err();
1044        assert!(err.contains("userId"), "unhelpful: {err}");
1045
1046        params.insert("userId".into(), serde_json::json!("me"));
1047        assert_eq!(
1048            build_path(service, method, &params).unwrap(),
1049            "gmail/v1/users/me/messages/m1"
1050        );
1051    }
1052
1053    #[test]
1054    fn path_values_are_percent_encoded() {
1055        let (service, method) = resolve("drive.files.get").unwrap();
1056        let mut params = serde_json::Map::new();
1057        params.insert("fileId".into(), serde_json::json!("a b/c+d"));
1058        let path = build_path(service, method, &params).unwrap();
1059        assert!(!path.contains(' '), "space leaked into {path}");
1060        assert!(path.contains("a%20b%2Fc%2Bd"), "not encoded: {path}");
1061    }
1062
1063    #[test]
1064    fn query_excludes_path_params_and_expands_arrays() {
1065        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1066        let mut params = serde_json::Map::new();
1067        params.insert("userId".into(), serde_json::json!("me"));
1068        params.insert("q".into(), serde_json::json!("is:unread"));
1069        params.insert("labelIds".into(), serde_json::json!(["INBOX", "UNREAD"]));
1070
1071        let query = build_query(method, &params);
1072        assert!(!query.contains("userId"), "path param leaked: {query}");
1073        assert!(query.contains("q=is%3Aunread"), "{query}");
1074        assert!(query.contains("labelIds=INBOX"), "{query}");
1075        assert!(query.contains("labelIds=UNREAD"), "{query}");
1076    }
1077
1078    #[test]
1079    fn query_is_empty_when_only_path_params_are_given() {
1080        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1081        let mut params = serde_json::Map::new();
1082        params.insert("userId".into(), serde_json::json!("me"));
1083        assert!(build_query(method, &params).is_empty());
1084    }
1085
1086    #[test]
1087    fn reserved_expansion_placeholders_match_their_parameters() {
1088        // Chat writes paths as `v1/{+name}` while the parameter is `name`:
1089        // the placeholder must resolve by its real name, not `+name`.
1090        let (service, method) = resolve("chat.spaces.get").unwrap();
1091        assert_eq!(method.path_placeholders(), ["name"]);
1092        assert_eq!(method.reserved_path_placeholders(), ["name"]);
1093
1094        let mut params = serde_json::Map::new();
1095        params.insert("name".into(), serde_json::json!("spaces/AAA"));
1096        let path = build_path(service, method, &params).unwrap();
1097        // The slash IS the path structure: reserved expansion keeps it.
1098        assert_eq!(path, "v1/spaces/AAA");
1099    }
1100
1101    #[test]
1102    fn reserved_expansion_only_encodes_non_path_characters() {
1103        let (service, method) = resolve("chat.spaces.get").unwrap();
1104        let mut params = serde_json::Map::new();
1105        params.insert("name".into(), serde_json::json!("spaces/A B?C"));
1106        let path = build_path(service, method, &params).unwrap();
1107        assert_eq!(path, "v1/spaces/A%20B%3FC");
1108    }
1109
1110    #[test]
1111    fn reserved_expansion_placeholders_do_not_leak_into_the_query() {
1112        // `name` was consumed by the path, so it must not also appear as a
1113        // query pair (a stale `{+name}` key used to hide it from the check).
1114        let (_, method) = resolve("chat.spaces.get").unwrap();
1115        let mut params = serde_json::Map::new();
1116        params.insert("name".into(), serde_json::json!("spaces/AAA"));
1117        assert!(build_query(method, &params).is_empty());
1118    }
1119
1120    #[test]
1121    fn plain_placeholders_still_percent_encode_slashes() {
1122        // Non-reserved placeholders keep the strict component encoding:
1123        // a Drive-style id with a slash must never smuggle in extra path
1124        // segments.
1125        let (service, method) = resolve("gmail.users.messages.get").unwrap();
1126        let mut params = serde_json::Map::new();
1127        params.insert("userId".into(), serde_json::json!("me"));
1128        params.insert("id".into(), serde_json::json!("a/b"));
1129        assert!(
1130            build_path(service, method, &params)
1131                .unwrap()
1132                .ends_with("a%2Fb")
1133        );
1134    }
1135
1136    #[test]
1137    fn every_service_base_has_no_leading_slash() {
1138        // rootUrl always ends in '/', so a leading slash on basePath yields
1139        // '//' and a 404 from Google. Gmail and Drive disagree here, which is
1140        // exactly why this is asserted rather than assumed.
1141        for (name, service) in services() {
1142            assert!(
1143                !service.base().starts_with('/'),
1144                "{name} base starts with a slash: {:?}",
1145                service.base()
1146            );
1147            let root = service.root_url.clone().unwrap_or_default();
1148            let sample = format!("{}{}", root, service.base());
1149            // Skip the scheme: "https://" is a legitimate double slash.
1150            let after_scheme = sample
1151                .split_once("://")
1152                .map(|(_, rest)| rest)
1153                .unwrap_or(&sample);
1154            assert!(
1155                !after_scheme.starts_with('/'),
1156                "{name} would build {sample}"
1157            );
1158        }
1159    }
1160
1161    #[test]
1162    fn method_ids_include_their_resource_path() {
1163        // Regression: the distiller once dropped the root resource name,
1164        // producing `messages.list` instead of `users.messages.list`.
1165        let (service, method) = resolve("gmail.users.messages.list").unwrap();
1166        assert_eq!(method.id, "users.messages.list");
1167        assert!(service.methods.iter().any(|m| m.id == "users.threads.get"));
1168    }
1169
1170    #[test]
1171    fn edit_distance_gate_behaves() {
1172        assert!(edit_distance_at_most("list", "lst", 2));
1173        assert!(edit_distance_at_most("files", "file", 2));
1174        assert!(!edit_distance_at_most("list", "trash", 2));
1175        // Length guard must reject before allocating.
1176        assert!(!edit_distance_at_most("a", "abcdefghij", 2));
1177    }
1178
1179    #[test]
1180    fn a_one_character_typo_ranks_the_real_method_first() {
1181        let err = resolve("gmail.users.messages.lst").unwrap_err();
1182        // Alphabetical ordering used to push the answer past the 5-item cut.
1183        let first = err
1184            .split("did you mean:")
1185            .nth(1)
1186            .expect("suggestion list")
1187            .trim()
1188            .lines()
1189            .next()
1190            .unwrap_or("")
1191            .trim()
1192            .trim_end_matches('.');
1193        assert!(
1194            first.contains("users.messages.list"),
1195            "best suggestion was `{first}` in: {err}"
1196        );
1197    }
1198
1199    #[test]
1200    fn most_methods_need_only_a_handful_of_scopes() {
1201        // This is why per-method authorisation is viable. Asking for the
1202        // union of everything would need 97 scopes and blow past the ~25
1203        // Google allows on an unverified app; asking for one method's scopes
1204        // needs a median of 3.
1205        let counts: Vec<usize> = services()
1206            .values()
1207            .flat_map(|s| s.methods.iter())
1208            .map(|m| m.scopes.len())
1209            .collect();
1210        let total = counts.len();
1211        let within_nine = counts.iter().filter(|c| **c <= 9).count();
1212        assert!(
1213            within_nine * 100 / total >= 90,
1214            "only {within_nine}/{total} methods need <=9 scopes"
1215        );
1216        // A handful of endpoints genuinely accept many (chat.spaceEvents.list
1217        // takes 16); the point is the tail, not the ceiling.
1218        let worst = counts.iter().max().copied().unwrap_or(0);
1219        assert!(worst <= 20, "a method accepts {worst} scopes");
1220    }
1221
1222    #[test]
1223    fn no_method_requests_the_full_workspace_surface_at_once() {
1224        // The reason per-method scopes exist: the union of every method's
1225        // scopes is far past what an unverified app may request at consent.
1226        let union: std::collections::BTreeSet<&str> = services()
1227            .values()
1228            .flat_map(|s| s.methods.iter())
1229            .flat_map(|m| m.scopes.iter().map(String::as_str))
1230            .collect();
1231        assert!(
1232            union.len() > 25,
1233            "expected the union to exceed Google's unverified-app ceiling, got {}",
1234            union.len()
1235        );
1236    }
1237}