sharepoint_cli/graph/
mod.rs1use std::time::Duration;
9
10use reqwest::{Method, Response, StatusCode};
11use serde::de::DeserializeOwned;
12use tokio::time::sleep;
13
14use crate::auth::AuthContext;
15use crate::error::{CliError, Result};
16
17const MAX_RETRIES: u32 = 3;
19const MAX_RETRY_AFTER_SECS: u64 = 60;
22
23#[derive(Clone)]
24pub struct GraphClient {
25 auth: AuthContext,
26}
27
28impl GraphClient {
29 pub fn new(auth: AuthContext) -> Self {
30 Self { auth }
31 }
32
33 pub fn auth(&self) -> &AuthContext {
34 &self.auth
35 }
36
37 pub async fn url(&self, path: &str) -> String {
40 if path.starts_with("http://") || path.starts_with("https://") {
41 return path.to_string();
42 }
43 let cfg = self.auth.config().await;
44 let base = cfg.graph_endpoint.trim_end_matches('/');
45 if let Some(rest) = path.strip_prefix('/') {
46 format!("{base}/{rest}")
47 } else {
48 format!("{base}/{path}")
49 }
50 }
51
52 pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
54 let resp = self.send(Method::GET, path, None).await?;
55 let body = resp.text().await?;
56 serde_json::from_str(&body)
57 .map_err(|e| CliError::Other(format!("graph response not JSON: {e}; body={body}")))
58 }
59
60 pub async fn send(
63 &self,
64 method: Method,
65 path: &str,
66 body: Option<Vec<u8>>,
67 ) -> Result<Response> {
68 let url = self.url(path).await;
69 let mut attempt: u32 = 0;
70 loop {
71 let token = self.auth.access_token().await?;
72 let http = self.auth.http().await;
73 let mut req = http
74 .request(method.clone(), &url)
75 .bearer_auth(&token)
76 .header("Accept", "application/json");
77 if let Some(b) = body.as_ref() {
78 req = req.body(b.clone());
79 }
80 let resp = req
81 .send()
82 .await
83 .map_err(|e| CliError::Http(format!("graph {method} {url}: {e}")))?;
84
85 let status = resp.status();
86 if status.is_success() {
87 return Ok(resp);
88 }
89
90 let retry_after = resp
91 .headers()
92 .get("Retry-After")
93 .and_then(|v| v.to_str().ok())
94 .and_then(|s| s.parse::<u64>().ok());
95
96 let body_text = resp.text().await.unwrap_or_default();
97 let cfg = self.auth.config().await;
98 let detail = if cfg.debug_http {
99 format!(": {body_text}")
100 } else {
101 String::new()
102 };
103
104 if status == StatusCode::TOO_MANY_REQUESTS && attempt < MAX_RETRIES {
105 let secs = retry_after
106 .map(|s| s.min(MAX_RETRY_AFTER_SECS))
107 .unwrap_or_else(|| 2u64.pow(attempt));
108 sleep(Duration::from_secs(secs)).await;
109 attempt += 1;
110 continue;
111 }
112 if status.is_server_error()
113 && attempt < MAX_RETRIES
114 && matches!(method, Method::GET | Method::HEAD)
115 {
116 let secs = 2u64.pow(attempt);
117 sleep(Duration::from_secs(secs)).await;
118 attempt += 1;
119 continue;
120 }
121
122 return Err(map_status(status, &body_text, &detail));
123 }
124 }
125
126 pub async fn page_all<T: DeserializeOwned>(&self, first_path: &str) -> Result<Vec<T>> {
128 let mut acc = Vec::new();
129 let mut next = Some(first_path.to_string());
130 while let Some(p) = next.take() {
131 let page: PagedResponse<T> = self.get_json(&p).await?;
132 acc.extend(page.value);
133 next = page.next_link;
134 }
135 Ok(acc)
136 }
137}
138
139#[derive(serde::Deserialize)]
140pub struct PagedResponse<T> {
141 pub value: Vec<T>,
142 #[serde(rename = "@odata.nextLink", default)]
143 pub next_link: Option<String>,
144}
145
146fn map_status(status: StatusCode, body: &str, detail: &str) -> CliError {
147 let primary = extract_graph_error_message(body).unwrap_or_else(|| status.to_string());
148 match status {
149 StatusCode::UNAUTHORIZED => CliError::Auth(format!("Graph 401: {primary}{detail}")),
150 StatusCode::FORBIDDEN => CliError::Auth(format!("Graph 403: {primary}{detail}")),
151 StatusCode::NOT_FOUND => CliError::NotFound(format!("{primary}{detail}")),
152 StatusCode::TOO_MANY_REQUESTS => CliError::RateLimit,
153 s => CliError::Api {
154 status: s.as_u16(),
155 message: format!("{primary}{detail}"),
156 },
157 }
158}
159
160fn extract_graph_error_message(body: &str) -> Option<String> {
161 let v: serde_json::Value = serde_json::from_str(body).ok()?;
162 let err = v.get("error")?;
163 let code = err.get("code").and_then(|c| c.as_str()).unwrap_or("");
164 let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("");
165 Some(if code.is_empty() {
166 msg.to_string()
167 } else {
168 format!("{code}: {msg}")
169 })
170}
171
172pub mod download;
173pub mod drives;
174pub mod search;
175pub mod sites;
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn extract_graph_error_message_handles_well_formed_body() {
183 let body =
184 r#"{"error":{"code":"itemNotFound","message":"The resource could not be found."}}"#;
185 let msg = extract_graph_error_message(body).unwrap();
186 assert!(msg.contains("itemNotFound"));
187 assert!(msg.contains("could not be found"));
188 }
189
190 #[test]
191 fn extract_graph_error_message_returns_none_for_non_json() {
192 assert!(extract_graph_error_message("not json").is_none());
193 }
194
195 #[test]
196 fn map_status_404_is_not_found() {
197 let body = r#"{"error":{"code":"itemNotFound","message":"missing"}}"#;
198 let err = map_status(StatusCode::NOT_FOUND, body, "");
199 assert!(matches!(err, CliError::NotFound(_)));
200 }
201
202 #[test]
203 fn map_status_429_is_rate_limit() {
204 let err = map_status(StatusCode::TOO_MANY_REQUESTS, "", "");
205 assert!(matches!(err, CliError::RateLimit));
206 }
207
208 #[test]
209 fn map_status_500_is_api_error() {
210 let err = map_status(StatusCode::INTERNAL_SERVER_ERROR, "", "");
211 match err {
212 CliError::Api { status, .. } => assert_eq!(status, 500),
213 _ => panic!("expected API error"),
214 }
215 }
216}