1use serde::Deserialize;
26use std::collections::BTreeMap;
27use std::sync::OnceLock;
28
29const 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 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 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 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 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
261pub fn cache_dir() -> Option<std::path::PathBuf> {
267 dirs::home_dir().map(|home| home.join(".grr").join("discovery"))
268}
269
270fn revision_rank(revision: &str) -> u64 {
274 revision.trim().parse::<u64>().unwrap_or(0)
275}
276
277pub 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 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 service.name = (*name).to_owned();
317 service.version = (*version).to_owned();
318 service
319 }
320 Err(error) => {
321 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 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
359pub 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 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 2
395 } else {
396 return None;
397 };
398 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
428fn 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
436pub(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
583pub 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 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 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
648async 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 let embedded_revision = services()
677 .get(name)
678 .and_then(|s| s.revision.clone())
679 .unwrap_or_default();
680
681 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#[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
752fn 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
782pub 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 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
839fn 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 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
891pub 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 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 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 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 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 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 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 let err = build_path(service, method, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms);
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, ¶ms).is_empty());
1084 }
1085
1086 #[test]
1087 fn reserved_expansion_placeholders_match_their_parameters() {
1088 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, ¶ms).unwrap();
1097 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, ¶ms).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 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, ¶ms).is_empty());
1118 }
1119
1120 #[test]
1121 fn plain_placeholders_still_percent_encode_slashes() {
1122 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, ¶ms)
1131 .unwrap()
1132 .ends_with("a%2Fb")
1133 );
1134 }
1135
1136 #[test]
1137 fn every_service_base_has_no_leading_slash() {
1138 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 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 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 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 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 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 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 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}