1use serde::Deserialize;
27use std::collections::BTreeMap;
28use std::sync::OnceLock;
29
30const SERVICES: &[(&str, &str, &str, &str)] = &[
33 (
34 "gmail.json",
35 "gmail",
36 "v1",
37 "https://gmail.googleapis.com/$discovery/rest?version=v1",
38 ),
39 (
40 "calendar.json",
41 "calendar",
42 "v3",
43 "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
44 ),
45 (
46 "drive.json",
47 "drive",
48 "v3",
49 "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
50 ),
51 (
52 "people.json",
53 "people",
54 "v1",
55 "https://people.googleapis.com/$discovery/rest?version=v1",
56 ),
57 (
58 "chat.json",
59 "chat",
60 "v1",
61 "https://chat.googleapis.com/$discovery/rest?version=v1",
62 ),
63 (
64 "forms.json",
65 "forms",
66 "v1",
67 "https://forms.googleapis.com/$discovery/rest?version=v1",
68 ),
69 (
70 "tasks.json",
71 "tasks",
72 "v1",
73 "https://tasks.googleapis.com/$discovery/rest?version=v1",
74 ),
75 (
76 "docs.json",
77 "docs",
78 "v1",
79 "https://docs.googleapis.com/$discovery/rest?version=v1",
80 ),
81 (
82 "sheets.json",
83 "sheets",
84 "v4",
85 "https://sheets.googleapis.com/$discovery/rest?version=v4",
86 ),
87 (
88 "slides.json",
89 "slides",
90 "v1",
91 "https://slides.googleapis.com/$discovery/rest?version=v1",
92 ),
93 (
94 "script.json",
95 "script",
96 "v1",
97 "https://script.googleapis.com/$discovery/rest?version=v1",
98 ),
99 (
100 "analyticsadmin.json",
101 "analyticsadmin",
102 "v1beta",
103 "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta",
104 ),
105 (
106 "analyticsdata.json",
107 "analyticsdata",
108 "v1beta",
109 "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta",
110 ),
111 (
112 "searchconsole.json",
113 "searchconsole",
114 "v1",
115 "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
116 ),
117];
118
119#[derive(Debug, Clone, serde::Serialize, Deserialize)]
120pub struct Parameter {
121 pub name: String,
122 #[serde(rename = "type")]
123 pub kind: String,
124 pub required: bool,
125 #[serde(default)]
126 pub repeated: bool,
127 #[serde(default)]
128 pub location: Option<String>,
129 #[serde(default)]
130 pub description: String,
131 #[serde(default, rename = "enum")]
132 pub enum_values: Option<Vec<String>>,
133}
134
135#[derive(Debug, Clone, serde::Serialize, Deserialize)]
136pub struct Method {
137 pub id: String,
138 #[serde(rename = "httpMethod")]
139 pub http_method: String,
140 pub path: String,
141 #[serde(default)]
142 pub flat_path: Option<String>,
143 #[serde(default)]
144 pub description: String,
145 #[serde(default)]
146 pub scopes: Vec<String>,
147 #[serde(default)]
148 pub required: Vec<String>,
149 #[serde(default)]
150 pub parameters: Vec<Parameter>,
151}
152
153impl Method {
154 pub fn least_privilege_scope(&self) -> Option<&str> {
162 self.scopes
163 .iter()
164 .rev()
165 .find(|s| s.starts_with("https://www.googleapis.com/auth/"))
166 .map(String::as_str)
167 .or_else(|| self.scopes.first().map(String::as_str))
168 }
169
170 pub fn parameter(&self, name: &str) -> Option<&Parameter> {
171 self.parameters.iter().find(|p| p.name == name)
172 }
173
174 pub fn path_placeholders(&self) -> Vec<&str> {
182 let mut out = Vec::new();
183 let mut rest = self.path.as_str();
184 while let Some(start) = rest.find('{') {
185 let Some(end) = rest[start..].find('}') else {
186 break;
187 };
188 let mut name = &rest[start + 1..start + end];
189 if name.starts_with('+') {
190 name = &name[1..];
191 }
192 out.push(name);
193 rest = &rest[start + end + 1..];
194 }
195 out
196 }
197
198 pub fn reserved_path_placeholders(&self) -> Vec<&str> {
202 let mut out = Vec::new();
203 let mut rest = self.path.as_str();
204 while let Some(start) = rest.find('{') {
205 let Some(end) = rest[start..].find('}') else {
206 break;
207 };
208 let name = &rest[start + 1..start + end];
209 if let Some(stripped) = name.strip_prefix('+') {
210 out.push(stripped);
211 }
212 rest = &rest[start + end + 1..];
213 }
214 out
215 }
216}
217
218#[derive(Debug, Clone, serde::Serialize, Deserialize)]
219pub struct Service {
220 pub name: String,
221 pub version: String,
222 #[serde(default)]
223 pub revision: Option<String>,
224 #[serde(default)]
225 pub title: String,
226 #[serde(default, rename = "rootUrl")]
227 pub root_url: Option<String>,
228 #[serde(default, rename = "servicePath")]
229 pub service_path: String,
230 #[serde(default, rename = "basePath")]
231 pub base_path: String,
232 #[serde(default, rename = "batchPath")]
233 pub batch_path: Option<String>,
234 pub methods: Vec<Method>,
235}
236
237impl Service {
238 pub fn method(&self, id: &str) -> Option<&Method> {
239 self.methods.iter().find(|m| m.id == id)
240 }
241
242 pub fn base(&self) -> String {
251 let raw = if self.base_path.is_empty() {
252 self.service_path.as_str()
253 } else {
254 self.base_path.as_str()
255 };
256 raw.trim_start_matches('/').to_owned()
257 }
258}
259
260static SERVICES_CACHE: OnceLock<BTreeMap<&'static str, Service>> = OnceLock::new();
261
262pub fn cache_dir() -> Option<std::path::PathBuf> {
268 dirs::home_dir().map(|home| home.join(".grr").join("discovery"))
269}
270
271fn revision_rank(revision: &str) -> u64 {
275 revision.trim().parse::<u64>().unwrap_or(0)
276}
277
278pub fn all_scopes() -> Vec<String> {
284 let mut scopes: Vec<String> = services()
285 .values()
286 .flat_map(|service| &service.methods)
287 .flat_map(|method| &method.scopes)
288 .cloned()
289 .collect();
290 scopes.sort_unstable();
291 scopes.dedup();
292 scopes
293}
294
295pub fn services() -> &'static BTreeMap<&'static str, Service> {
305 SERVICES_CACHE.get_or_init(|| {
306 let mut map: BTreeMap<&'static str, Service> = BTreeMap::new();
307 for (file, name, version, _url) in SERVICES {
308 let body = match *file {
313 "gmail.json" => include_str!("discovery/gmail.json"),
314 "calendar.json" => include_str!("discovery/calendar.json"),
315 "drive.json" => include_str!("discovery/drive.json"),
316 "people.json" => include_str!("discovery/people.json"),
317 "chat.json" => include_str!("discovery/chat.json"),
318 "forms.json" => include_str!("discovery/forms.json"),
319 "tasks.json" => include_str!("discovery/tasks.json"),
320 "docs.json" => include_str!("discovery/docs.json"),
321 "sheets.json" => include_str!("discovery/sheets.json"),
322 "slides.json" => include_str!("discovery/slides.json"),
323 "script.json" => include_str!("discovery/script.json"),
324 "analyticsadmin.json" => include_str!("discovery/analyticsadmin.json"),
325 "analyticsdata.json" => include_str!("discovery/analyticsdata.json"),
326 "searchconsole.json" => include_str!("discovery/searchconsole.json"),
327 _ => panic!("no embedded discovery index for {file}; add an include_str! arm"),
328 };
329 let embedded = match serde_json::from_str::<Service>(body) {
330 Ok(mut service) => {
331 service.name = (*name).to_owned();
335 service.version = (*version).to_owned();
336 service
337 }
338 Err(error) => {
339 panic!("embedded discovery index {file} failed to parse: {error}");
343 }
344 };
345
346 let fresher = cache_dir().and_then(|dir| {
347 let path = dir.join(format!("{name}.json"));
348 let Ok(body) = std::fs::read_to_string(&path) else {
349 return None;
350 };
351 match serde_json::from_str::<Service>(&body) {
352 Ok(cached)
355 if revision_rank(cached.revision.as_deref().unwrap_or(""))
356 > revision_rank(embedded.revision.as_deref().unwrap_or("")) =>
357 {
358 Some(cached)
359 }
360 Ok(_) => None,
361 Err(error) => {
362 tracing::warn!(
363 "discovery cache {} is corrupt ({error}); using the embedded index",
364 path.display()
365 );
366 None
367 }
368 }
369 });
370
371 map.insert(name, fresher.unwrap_or(embedded));
372 }
373 map
374 })
375}
376
377pub fn resolve(id: &str) -> Result<(&'static Service, &'static Method), String> {
380 let id = id.trim().trim_start_matches('.').replace('/', ".");
381 let (service_name, method_id) = id.split_once('.').ok_or_else(|| {
382 format!("`{id}` is not a method id; expected <service>.<resource>.<method>")
383 })?;
384 let service = services().get(service_name).ok_or_else(|| {
385 let known: Vec<&str> = services().keys().copied().collect();
386 format!(
387 "unknown service `{service_name}`; known: {}",
388 known.join(", ")
389 )
390 })?;
391 let method = service.method(method_id).ok_or_else(|| {
392 let mut ids: Vec<&str> = service.methods.iter().map(|m| m.id.as_str()).collect();
393 ids.sort_unstable();
394 let wanted = method_id.rsplit('.').next().unwrap_or(method_id);
399 let mut scored: Vec<(usize, std::cmp::Reverse<usize>, &str)> = ids
400 .iter()
401 .copied()
402 .filter_map(|candidate| {
403 let actual = candidate.rsplit('.').next().unwrap_or(candidate);
404 let bucket = if actual == wanted {
405 0
406 } else if actual.starts_with(wanted) || wanted.starts_with(actual) {
407 1
408 } else if edit_distance_at_most(actual, wanted, 1) {
409 2
413 } else {
414 return None;
415 };
416 Some((
419 bucket,
420 std::cmp::Reverse(shared_prefix(candidate, method_id)),
421 candidate,
422 ))
423 })
424 .collect();
425 scored.sort();
426 let mut near: Vec<&str> = scored
427 .into_iter()
428 .map(|(_, _, candidate)| candidate)
429 .collect();
430 near.truncate(5);
431 if near.is_empty() {
432 format!(
433 "unknown method `{method_id}` in `{service_name}` ({} methods available)",
434 service.methods.len()
435 )
436 } else {
437 format!(
438 "unknown method `{method_id}`; did you mean: {}",
439 near.join(", ")
440 )
441 }
442 })?;
443 Ok((service, method))
444}
445
446fn shared_prefix(left: &str, right: &str) -> usize {
448 left.chars()
449 .zip(right.chars())
450 .take_while(|(a, b)| a == b)
451 .count()
452}
453
454pub(crate) fn distill(doc: &serde_json::Value) -> Result<Vec<Method>, String> {
469 let mut out = Vec::new();
470 let resources = doc
471 .get("resources")
472 .and_then(|r| r.as_object())
473 .ok_or_else(|| "document has no resources object".to_owned())?;
474
475 fn walk(node: &serde_json::Value, path: &str, out: &mut Vec<Method>) {
476 let Some(object) = node.as_object() else {
477 return;
478 };
479 if let Some(methods) = object.get("methods").and_then(|m| m.as_object()) {
480 for (name, method) in methods {
481 let id = if path.is_empty() {
482 name.clone()
483 } else {
484 format!("{path}.{name}")
485 };
486 let http_method = method
487 .get("httpMethod")
488 .and_then(|v| v.as_str())
489 .unwrap_or("GET")
490 .to_owned();
491 let path_template = method
492 .get("path")
493 .and_then(|v| v.as_str())
494 .unwrap_or("")
495 .to_owned();
496 let flat_path = method
497 .get("flatPath")
498 .and_then(|v| v.as_str())
499 .map(str::to_owned);
500 let description = method
501 .get("description")
502 .and_then(|v| v.as_str())
503 .unwrap_or("")
504 .split_whitespace()
505 .collect::<Vec<_>>()
506 .join(" ");
507 let description = description.chars().take(300).collect::<String>();
508 let scopes = method
509 .get("scopes")
510 .and_then(|v| v.as_array())
511 .map(|a| {
512 a.iter()
513 .filter_map(|s| s.as_str().map(str::to_owned))
514 .collect::<Vec<String>>()
515 })
516 .unwrap_or_default();
517 let required = method
518 .get("parameterOrder")
519 .and_then(|v| v.as_array())
520 .map(|a| {
521 a.iter()
522 .filter_map(|s| s.as_str().map(str::to_owned))
523 .collect::<Vec<String>>()
524 })
525 .unwrap_or_default();
526
527 let mut parameters: Vec<Parameter> = method
528 .get("parameters")
529 .and_then(|p| p.as_object())
530 .map(|params| {
531 params
532 .iter()
533 .map(|(pname, p)| Parameter {
534 name: pname.clone(),
535 kind: p
536 .get("type")
537 .and_then(|v| v.as_str())
538 .unwrap_or("string")
539 .to_owned(),
540 required: required.iter().any(|r| r == pname),
541 repeated: p
542 .get("repeated")
543 .and_then(|v| v.as_bool())
544 .unwrap_or(false),
545 location: p
546 .get("location")
547 .and_then(|v| v.as_str())
548 .map(str::to_owned),
549 description: {
550 let text = p
551 .get("description")
552 .and_then(|v| v.as_str())
553 .unwrap_or("")
554 .split_whitespace()
555 .collect::<Vec<_>>()
556 .join(" ");
557 text.chars().take(160).collect::<String>()
558 },
559 enum_values: p.get("enum").and_then(|v| v.as_array()).map(|a| {
560 a.iter()
561 .filter_map(|s| s.as_str().map(str::to_owned))
562 .collect()
563 }),
564 })
565 .collect()
566 })
567 .unwrap_or_default();
568 parameters.sort_by(|a, b| a.name.cmp(&b.name));
569
570 out.push(Method {
571 id,
572 http_method,
573 path: path_template,
574 flat_path,
575 description,
576 scopes,
577 required,
578 parameters,
579 });
580 }
581 }
582 if let Some(nested) = object.get("resources").and_then(|r| r.as_object()) {
583 for (rname, resource) in nested {
584 let child = if path.is_empty() {
585 rname.clone()
586 } else {
587 format!("{path}.{rname}")
588 };
589 walk(resource, &child, out);
590 }
591 }
592 }
593
594 for (name, resource) in resources {
595 walk(resource, name, &mut out);
596 }
597 out.sort_by(|a, b| a.id.cmp(&b.id));
598 Ok(out)
599}
600
601pub async fn refresh(filter: Option<&str>) -> Result<Vec<RefreshReport>, String> {
609 use crate::core::http::build_http_client;
610
611 let client = build_http_client().map_err(|e| e.to_string())?;
612 let Some(dir) = cache_dir() else {
613 return Err("could not find your home directory".to_owned());
614 };
615 tokio::fs::create_dir_all(&dir)
616 .await
617 .map_err(|e| e.to_string())?;
618
619 let mut tasks = tokio::task::JoinSet::new();
624 let mut wanted = 0usize;
625 for (file, name, version, url) in SERVICES {
626 if let Some(wanted_name) = filter
627 && !name.eq_ignore_ascii_case(wanted_name)
628 {
629 continue;
630 }
631 wanted += 1;
632 let client = client.clone();
633 let dir = dir.clone();
634 tasks.spawn(async move { fetch_one(&client, file, name, version, url, &dir).await });
635 }
636 if wanted == 0 {
637 return Ok(Vec::new());
638 }
639
640 let mut reports = Vec::with_capacity(wanted);
641 while let Some(joined) = tasks.join_next().await {
642 match joined {
643 Ok(Ok(report)) => reports.push(report),
644 Ok(Err(e)) => {
645 tasks.abort_all();
649 return Err(e);
650 }
651 Err(join_error) => {
652 tasks.abort_all();
653 return Err(format!("refresh task failed: {join_error}"));
654 }
655 }
656 }
657 reports.sort_by_key(|report| {
658 SERVICES
659 .iter()
660 .position(|(_, name, _, _)| *name == report.service)
661 .unwrap_or(usize::MAX)
662 });
663 Ok(reports)
664}
665
666async fn fetch_one(
669 client: &reqwest::Client,
670 file: &str,
671 name: &str,
672 version: &str,
673 url: &str,
674 dir: &std::path::Path,
675) -> Result<RefreshReport, String> {
676 let body = client
677 .get(url::Url::parse(url).map_err(|e| format!("{name}: bad discovery url: {e}"))?)
678 .send()
679 .await
680 .map_err(|e| format!("{name}: {e}"))?
681 .text()
682 .await
683 .map_err(|e| format!("{name}: {e}"))?;
684
685 let doc: serde_json::Value = serde_json::from_str(&body)
686 .map_err(|e| format!("{name}: discovery response is not JSON: {e}"))?;
687 let revision = doc
688 .get("revision")
689 .and_then(|v| v.as_str())
690 .unwrap_or("")
691 .to_owned();
692
693 let embedded_revision = services()
695 .get(name)
696 .and_then(|s| s.revision.clone())
697 .unwrap_or_default();
698
699 let methods = distill(&doc)?;
703 if revision_rank(&revision) < revision_rank(&embedded_revision) {
704 return Ok(RefreshReport {
705 service: name.to_owned(),
706 from: embedded_revision,
707 to: revision,
708 methods: methods.len(),
709 wrote_cache: false,
710 skipped_older: true,
711 });
712 }
713
714 let index = Service {
715 name: name.to_owned(),
716 version: version.to_owned(),
717 revision: Some(revision.clone()),
718 title: doc
719 .get("title")
720 .and_then(|v| v.as_str())
721 .unwrap_or(name)
722 .to_owned(),
723 root_url: doc
724 .get("rootUrl")
725 .and_then(|v| v.as_str())
726 .map(str::to_owned),
727 service_path: doc
728 .get("servicePath")
729 .and_then(|v| v.as_str())
730 .unwrap_or("")
731 .to_owned(),
732 base_path: doc
733 .get("basePath")
734 .and_then(|v| v.as_str())
735 .unwrap_or("")
736 .to_owned(),
737 batch_path: doc
738 .get("batchPath")
739 .and_then(|v| v.as_str())
740 .map(str::to_owned),
741 methods,
742 };
743 let json = serde_json::to_string(&index).map_err(|e| e.to_string())?;
744 let path = dir.join(file);
745 tokio::fs::write(&path, json)
746 .await
747 .map_err(|e| format!("{}: {e}", path.display()))?;
748
749 Ok(RefreshReport {
750 service: name.to_owned(),
751 from: embedded_revision,
752 to: revision,
753 methods: index.methods.len(),
754 wrote_cache: true,
755 skipped_older: false,
756 })
757}
758
759#[derive(Debug, Clone, serde::Serialize)]
761pub struct RefreshReport {
762 pub service: String,
763 pub from: String,
764 pub to: String,
765 pub methods: usize,
766 pub wrote_cache: bool,
767 pub skipped_older: bool,
768}
769
770fn edit_distance_at_most(left: &str, right: &str, limit: usize) -> bool {
775 let a: Vec<char> = left.chars().collect();
776 let b: Vec<char> = right.chars().collect();
777 if a.len().abs_diff(b.len()) > limit {
778 return false;
779 }
780 let mut previous: Vec<usize> = (0..=b.len()).collect();
781 let mut current = vec![0usize; b.len() + 1];
782 for i in 1..=a.len() {
783 current[0] = i;
784 let mut row_best = current[0];
785 for j in 1..=b.len() {
786 let cost = usize::from(a[i - 1] != b[j - 1]);
787 current[j] = (previous[j] + 1)
788 .min(current[j - 1] + 1)
789 .min(previous[j - 1] + cost);
790 row_best = row_best.min(current[j]);
791 }
792 if row_best > limit {
793 return false;
794 }
795 std::mem::swap(&mut previous, &mut current);
796 }
797 previous[b.len()] <= limit
798}
799
800pub fn build_path(
812 service: &Service,
813 method: &Method,
814 params: &serde_json::Map<String, serde_json::Value>,
815) -> Result<String, String> {
816 let reserved = method.reserved_path_placeholders();
817 let mut path = method.path.clone();
818 for placeholder in method.path_placeholders() {
819 let Some(value) = params.get(placeholder) else {
820 let known: Vec<&str> = method
821 .parameters
822 .iter()
823 .filter(|p| p.required)
824 .map(|p| p.name.as_str())
825 .collect();
826 return Err(format!(
827 "`{}` needs the `{placeholder}` parameter (path). Required: {}",
828 method.id,
829 if known.is_empty() {
830 "none".to_owned()
831 } else {
832 known.join(", ")
833 }
834 ));
835 };
836 let text = value_to_string(value);
841 let encoded = if reserved.contains(&placeholder) {
842 encode_path_reserved(&text)
843 } else {
844 urlencoding::encode(&text).into_owned()
845 };
846 path = path
847 .replace(&format!("{{{placeholder}}}"), &encoded)
848 .replace(&format!("{{+{placeholder}}}"), &encoded);
849 }
850 Ok(format!(
851 "{}{}",
852 service.base(),
853 path.trim_start_matches('/')
854 ))
855}
856
857fn encode_path_reserved(value: &str) -> String {
862 let mut out = String::with_capacity(value.len());
863 for byte in value.bytes() {
864 match byte {
865 b'A'..=b'Z'
866 | b'a'..=b'z'
867 | b'0'..=b'9'
868 | b'-'
869 | b'.'
870 | b'_'
871 | b'~'
872 | b'/'
873 | b':'
874 | b'@'
875 | b'!'
876 | b'$'
877 | b'&'
878 | b'\''
879 | b'('
880 | b')'
881 | b'*'
882 | b'+'
883 | b','
884 | b';'
885 | b'=' => {
886 out.push(byte as char);
887 }
888 _ => {
889 const HEX: &[u8; 16] = b"0123456789ABCDEF";
892 out.push('%');
893 out.push(HEX[(byte >> 4) as usize] as char);
894 out.push(HEX[(byte & 0x0F) as usize] as char);
895 }
896 }
897 }
898 out
899}
900
901pub fn value_to_string(value: &serde_json::Value) -> String {
902 match value {
903 serde_json::Value::String(s) => s.clone(),
904 serde_json::Value::Null => String::new(),
905 other => other.to_string(),
906 }
907}
908
909pub fn build_query(method: &Method, params: &serde_json::Map<String, serde_json::Value>) -> String {
913 let placeholders = method.path_placeholders();
914 let mut pairs: Vec<(String, String)> = Vec::new();
915 for (key, value) in params {
916 if placeholders.contains(&key.as_str()) {
917 continue;
918 }
919 if let Some(param) = method.parameter(key)
920 && param.location.as_deref() == Some("path")
921 {
922 continue;
923 }
924 match value {
925 serde_json::Value::Array(items) => {
926 for item in items {
927 pairs.push((key.clone(), value_to_string(item)));
928 }
929 }
930 serde_json::Value::Null => {}
931 other => pairs.push((key.clone(), value_to_string(other))),
932 }
933 }
934 if pairs.is_empty() {
935 return String::new();
936 }
937 pairs.sort();
938 let mut query = String::with_capacity(pairs.len() * 16);
941 for (k, v) in &pairs {
942 if !query.is_empty() {
943 query.push('&');
944 }
945 query.push_str(&urlencoding::encode(k));
946 query.push('=');
947 query.push_str(&urlencoding::encode(v));
948 }
949 format!("?{query}")
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 #[test]
957 fn every_service_parses_and_has_methods() {
958 let all = services();
959 assert_eq!(all.len(), SERVICES.len());
960 for (name, service) in all {
961 assert!(!service.methods.is_empty(), "{name} has no methods");
962 }
963 }
964
965 #[test]
966 fn the_index_covers_the_whole_workspace_surface() {
967 let total: usize = services().values().map(|s| s.methods.len()).sum();
970 assert!(
971 total >= 300,
972 "expected 300+ methods across the Workspace APIs, found {total}"
973 );
974 }
975
976 #[test]
977 fn every_service_embeds_its_own_index_not_its_neighbors() {
978 let all = services();
983 let distinct: std::collections::BTreeSet<String> = all
984 .values()
985 .map(|s| {
986 s.methods
987 .iter()
988 .map(|m| m.id.as_str())
989 .collect::<Vec<_>>()
990 .join(",")
991 })
992 .collect();
993 assert_eq!(
994 distinct.len(),
995 all.len(),
996 "two services share a method-id set — an include_str! arm is missing"
997 );
998 }
999
1000 #[test]
1001 fn resolves_a_nested_method_id() {
1002 let (service, method) =
1003 resolve("gmail.users.messages.list").expect("gmail.users.messages.list");
1004 assert_eq!(service.name, "gmail");
1005 assert_eq!(method.http_method, "GET");
1006 assert_eq!(method.path, "gmail/v1/users/{userId}/messages");
1007 }
1008
1009 #[test]
1010 fn resolves_a_method_without_an_intermediate_resource() {
1011 let (service, method) = resolve("gmail.users.getProfile").expect("gmail.users.getProfile");
1014 assert_eq!(service.name, "gmail");
1015 assert_eq!(method.http_method, "GET");
1016 }
1017
1018 #[test]
1019 fn accepts_a_leading_dot_and_slashes() {
1020 assert!(resolve(".gmail.users.messages.list").is_ok());
1021 assert!(resolve("gmail/users/messages/list").is_ok());
1022 }
1023
1024 #[test]
1025 fn unknown_service_and_method_are_actionable() {
1026 let err = resolve("gmail.users.messages.lst").unwrap_err();
1028 assert!(err.contains("did you mean"), "unhelpful: {err}");
1029 let err = resolve("nope.thing.method").unwrap_err();
1030 assert!(err.contains("known:"), "should list services: {err}");
1031 let err = resolve("gmailonly").unwrap_err();
1032 assert!(err.contains("expected <service>"), "unhelpful: {err}");
1033 }
1034
1035 #[test]
1036 fn all_scopes_is_the_sorted_union_of_every_method() {
1037 let union = all_scopes();
1038 assert!(
1039 union.windows(2).all(|pair| pair[0] < pair[1]),
1040 "the union must be sorted and deduped"
1041 );
1042 for service in services().values() {
1043 for method in &service.methods {
1044 for scope in &method.scopes {
1045 assert!(
1046 union.binary_search(scope).is_ok(),
1047 "{scope} is not in the union"
1048 );
1049 }
1050 }
1051 }
1052 }
1053
1054 #[test]
1055 fn the_consent_set_contains_every_indexed_scope_google_will_show() {
1056 let consent = crate::core::auth::scopes();
1064 for scope in all_scopes() {
1065 if crate::core::auth::APP_AUTH_SCOPES.contains(&scope.as_str()) {
1066 assert!(
1067 !consent.contains(&scope),
1068 "{scope} is app-authentication-only and must not be requested"
1069 );
1070 continue;
1071 }
1072 assert!(
1073 consent.contains(&scope),
1074 "{scope} is indexed but not consented"
1075 );
1076 }
1077 for scope in [
1079 "https://mail.google.com/",
1080 "https://www.googleapis.com/auth/gmail.readonly",
1081 "https://www.googleapis.com/auth/webmasters.readonly",
1082 ] {
1083 assert!(
1084 consent.iter().any(|consented| consented == scope),
1085 "{scope} was dropped from the consent set"
1086 );
1087 }
1088 }
1089
1090 #[test]
1091 fn least_privilege_scope_prefers_the_narrow_one() {
1092 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1093 let scope = method.least_privilege_scope().expect("a scope");
1096 assert!(scope.ends_with("gmail.readonly"), "picked {scope}");
1097 }
1098
1099 #[test]
1100 fn required_parameters_are_flagged() {
1101 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1102 let user_id = method.parameter("userId").expect("userId param");
1103 assert!(user_id.required);
1104 let max = method.parameter("maxResults").expect("maxResults param");
1105 assert!(!max.required);
1106 }
1107
1108 #[test]
1109 fn builds_a_path_and_reports_missing_placeholders() {
1110 let (service, method) = resolve("gmail.users.messages.get").unwrap();
1111 let mut params = serde_json::Map::new();
1112 params.insert("id".into(), serde_json::json!("m1"));
1113
1114 let err = build_path(service, method, ¶ms).unwrap_err();
1117 assert!(err.contains("userId"), "unhelpful: {err}");
1118
1119 params.insert("userId".into(), serde_json::json!("me"));
1120 assert_eq!(
1121 build_path(service, method, ¶ms).unwrap(),
1122 "gmail/v1/users/me/messages/m1"
1123 );
1124 }
1125
1126 #[test]
1127 fn path_values_are_percent_encoded() {
1128 let (service, method) = resolve("drive.files.get").unwrap();
1129 let mut params = serde_json::Map::new();
1130 params.insert("fileId".into(), serde_json::json!("a b/c+d"));
1131 let path = build_path(service, method, ¶ms).unwrap();
1132 assert!(!path.contains(' '), "space leaked into {path}");
1133 assert!(path.contains("a%20b%2Fc%2Bd"), "not encoded: {path}");
1134 }
1135
1136 #[test]
1137 fn query_excludes_path_params_and_expands_arrays() {
1138 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1139 let mut params = serde_json::Map::new();
1140 params.insert("userId".into(), serde_json::json!("me"));
1141 params.insert("q".into(), serde_json::json!("is:unread"));
1142 params.insert("labelIds".into(), serde_json::json!(["INBOX", "UNREAD"]));
1143
1144 let query = build_query(method, ¶ms);
1145 assert!(!query.contains("userId"), "path param leaked: {query}");
1146 assert!(query.contains("q=is%3Aunread"), "{query}");
1147 assert!(query.contains("labelIds=INBOX"), "{query}");
1148 assert!(query.contains("labelIds=UNREAD"), "{query}");
1149 }
1150
1151 #[test]
1152 fn query_is_empty_when_only_path_params_are_given() {
1153 let (_, method) = resolve("gmail.users.messages.list").unwrap();
1154 let mut params = serde_json::Map::new();
1155 params.insert("userId".into(), serde_json::json!("me"));
1156 assert!(build_query(method, ¶ms).is_empty());
1157 }
1158
1159 #[test]
1160 fn reserved_expansion_placeholders_match_their_parameters() {
1161 let (service, method) = resolve("chat.spaces.get").unwrap();
1164 assert_eq!(method.path_placeholders(), ["name"]);
1165 assert_eq!(method.reserved_path_placeholders(), ["name"]);
1166
1167 let mut params = serde_json::Map::new();
1168 params.insert("name".into(), serde_json::json!("spaces/AAA"));
1169 let path = build_path(service, method, ¶ms).unwrap();
1170 assert_eq!(path, "v1/spaces/AAA");
1172 }
1173
1174 #[test]
1175 fn reserved_expansion_only_encodes_non_path_characters() {
1176 let (service, method) = resolve("chat.spaces.get").unwrap();
1177 let mut params = serde_json::Map::new();
1178 params.insert("name".into(), serde_json::json!("spaces/A B?C"));
1179 let path = build_path(service, method, ¶ms).unwrap();
1180 assert_eq!(path, "v1/spaces/A%20B%3FC");
1181 }
1182
1183 #[test]
1184 fn reserved_expansion_placeholders_do_not_leak_into_the_query() {
1185 let (_, method) = resolve("chat.spaces.get").unwrap();
1188 let mut params = serde_json::Map::new();
1189 params.insert("name".into(), serde_json::json!("spaces/AAA"));
1190 assert!(build_query(method, ¶ms).is_empty());
1191 }
1192
1193 #[test]
1194 fn plain_placeholders_still_percent_encode_slashes() {
1195 let (service, method) = resolve("gmail.users.messages.get").unwrap();
1199 let mut params = serde_json::Map::new();
1200 params.insert("userId".into(), serde_json::json!("me"));
1201 params.insert("id".into(), serde_json::json!("a/b"));
1202 assert!(
1203 build_path(service, method, ¶ms)
1204 .unwrap()
1205 .ends_with("a%2Fb")
1206 );
1207 }
1208
1209 #[test]
1210 fn every_service_base_has_no_leading_slash() {
1211 for (name, service) in services() {
1215 assert!(
1216 !service.base().starts_with('/'),
1217 "{name} base starts with a slash: {:?}",
1218 service.base()
1219 );
1220 let root = service.root_url.clone().unwrap_or_default();
1221 let sample = format!("{}{}", root, service.base());
1222 let after_scheme = sample
1224 .split_once("://")
1225 .map(|(_, rest)| rest)
1226 .unwrap_or(&sample);
1227 assert!(
1228 !after_scheme.starts_with('/'),
1229 "{name} would build {sample}"
1230 );
1231 }
1232 }
1233
1234 #[test]
1235 fn method_ids_include_their_resource_path() {
1236 let (service, method) = resolve("gmail.users.messages.list").unwrap();
1239 assert_eq!(method.id, "users.messages.list");
1240 assert!(service.methods.iter().any(|m| m.id == "users.threads.get"));
1241 }
1242
1243 #[test]
1244 fn edit_distance_gate_behaves() {
1245 assert!(edit_distance_at_most("list", "lst", 2));
1246 assert!(edit_distance_at_most("files", "file", 2));
1247 assert!(!edit_distance_at_most("list", "trash", 2));
1248 assert!(!edit_distance_at_most("a", "abcdefghij", 2));
1250 }
1251
1252 #[test]
1253 fn a_one_character_typo_ranks_the_real_method_first() {
1254 let err = resolve("gmail.users.messages.lst").unwrap_err();
1255 let first = err
1257 .split("did you mean:")
1258 .nth(1)
1259 .expect("suggestion list")
1260 .trim()
1261 .lines()
1262 .next()
1263 .unwrap_or("")
1264 .trim()
1265 .trim_end_matches('.');
1266 assert!(
1267 first.contains("users.messages.list"),
1268 "best suggestion was `{first}` in: {err}"
1269 );
1270 }
1271
1272 #[test]
1273 fn most_methods_need_only_a_handful_of_scopes() {
1274 let counts: Vec<usize> = services()
1278 .values()
1279 .flat_map(|s| s.methods.iter())
1280 .map(|m| m.scopes.len())
1281 .collect();
1282 let total = counts.len();
1283 let within_nine = counts.iter().filter(|c| **c <= 9).count();
1284 assert!(
1285 within_nine * 100 / total >= 90,
1286 "only {within_nine}/{total} methods need <=9 scopes"
1287 );
1288 let worst = counts.iter().max().copied().unwrap_or(0);
1291 assert!(worst <= 20, "a method accepts {worst} scopes");
1292 }
1293}