gitlab_time_report/fetch_api/
mod.rs1mod api_model;
4mod deserializer;
5mod fetch_options;
6mod http_requests;
7
8use crate::fetch_api::api_model::ApiResponse;
9use crate::fetch_api::http_requests::NetworkError;
10use crate::model::Project;
11use chrono::Duration;
12pub use fetch_options::FetchOptions;
13use reqwest::blocking::Client;
14use serde_json::{Error, json};
15use thiserror::Error;
16
17fn run_query(
25 payload: serde_json::Value,
26 client: &impl http_requests::HttpFetcher,
27 fetch_options: &FetchOptions,
28) -> Result<String, QueryError> {
29 let url = format!(
30 "{}://{}/api/graphql",
31 fetch_options.protocol, fetch_options.host
32 );
33 let response = client.http_post_request(&url, payload, fetch_options);
34
35 response.map_err(QueryError::NetworkError)
37}
38
39#[cfg(not(tarpaulin_include))]
58pub fn fetch_project_time_logs(options: &FetchOptions) -> Result<Project, QueryError> {
59 let http_client = Client::new();
60 fetch_project_time_logs_impl(options, &http_client)
61}
62
63fn fetch_project_time_logs_impl(
65 options: &FetchOptions,
66 http_client: &impl http_requests::HttpFetcher,
67) -> Result<Project, QueryError> {
68 let query_template = include_str!("query_project_time_logs.graphql");
69
70 let mut time_logs = Vec::new();
71 let mut name = String::new();
72 let mut total_spent_time = Duration::default();
73 let mut cursor: Option<String> = None;
74 let mut has_next_page = true;
75
76 while has_next_page {
78 let payload = build_query_payload(query_template, &options.path, cursor.as_deref());
79 let response = run_query(payload, http_client, options)?;
80
81 let deserializer = &mut serde_json::Deserializer::from_str(&response);
83 let model: ApiResponse = serde_path_to_error::deserialize(deserializer)?;
86
87 let project = validate_model(model, options)?;
88
89 has_next_page = project.timelogs.page_info.has_next_page;
91 cursor = project.timelogs.page_info.end_cursor;
92
93 if !has_next_page {
95 name = project.name;
96 total_spent_time = project.timelogs.total_spent_time;
97 }
98
99 time_logs.extend(project.timelogs.nodes);
101 }
102
103 let time_logs = time_logs.into_iter().flatten().collect();
105
106 Ok(Project {
107 name,
108 time_logs,
109 total_spent_time,
110 })
111}
112
113fn build_query_payload(
115 template: &str,
116 project_path: &str,
117 cursor: Option<&str>,
118) -> serde_json::Value {
119 let variables = match cursor {
120 Some(c) => json!({
121 "projectPath": project_path,
122 "after": c,
123 }),
124 None => json!({
125 "projectPath": project_path,
126 "after": null,
127 }),
128 };
129
130 json!({
131 "query": template,
132 "variables": variables,
133 })
134}
135
136fn validate_model(
138 model: ApiResponse,
139 options: &FetchOptions,
140) -> Result<api_model::Project, QueryError> {
141 if let Some(errors) = &model.errors {
143 return Err(QueryError::GraphQlError(errors[0].message.clone()));
144 }
145
146 let project = model.data.project.ok_or_else(|| {
147 QueryError::ProjectNotFound(format!("{}/{}", options.host, options.path))
149 })?;
150 Ok(project)
151}
152
153#[derive(Debug, Error)]
155pub enum QueryError {
156 #[error("A network error has occurred: {0}")]
158 NetworkError(NetworkError),
159 #[error("Project '{0}' not found")]
161 ProjectNotFound(String),
162 #[error("Error with the GraphQL query: {0}")]
164 GraphQlError(String),
165 #[error("Could not deserialize response: {0}")]
167 JsonParseError(#[from] serde_path_to_error::Error<Error>),
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::fetch_api::http_requests::MockHttpFetcher;
174
175 const URL: &str = "https://gitlab.com/test-user/test-project";
176 const PROJECT_NAME: &str = "Test Repo";
177
178 #[test]
179 fn fetch_project_correctly() {
180 let options = FetchOptions::new(URL, None).unwrap();
181 let mut mock = MockHttpFetcher::new();
182 mock.expect_http_post_request().return_const({
183 Ok(r#"{"data": { "project": { "name": "Test Repo", "timelogs": {"pageInfo": {"hasNextPage": false, "endCursor": null}, "totalSpentTime": "20", "nodes": []}}}}"#.into())
184 });
185
186 let result = fetch_project_time_logs_impl(&options, &mock);
187 assert!(result.is_ok());
188 assert_eq!(result.unwrap().name, PROJECT_NAME);
189 }
190
191 #[test]
192 fn fetch_project_with_pagination() {
193 const JSON_TEMPLATE: &str = r#"{"data":{"project":{"name":"Test Repo","timelogs":{"pageInfo":{"hasNextPage":$NEXT,"endCursor":"$CURSOR"}, "totalSpentTime": "20", "nodes":[]}}}}"#;
194
195 let options = FetchOptions::new(URL, None).unwrap();
196 let mut mock = MockHttpFetcher::new();
197
198 mock.expect_http_post_request()
200 .times(1) .withf(|_, payload, _| {
202 let after_value = payload.get("variables").unwrap().get("after").unwrap();
203 after_value.is_null()
204 })
205 .return_const(Ok(JSON_TEMPLATE
206 .replace("$NEXT", "true")
207 .replace("$CURSOR", "firstCursor")));
208
209 mock.expect_http_post_request()
211 .times(1)
212 .withf(|_, payload, _| {
213 let after_value = payload.get("variables").unwrap().get("after").unwrap();
214 after_value.as_str() == Some("firstCursor")
215 })
216 .return_const(Ok(JSON_TEMPLATE
217 .replace("$NEXT", "true")
218 .replace("$CURSOR", "secondCursor")));
219
220 mock.expect_http_post_request()
222 .times(1)
223 .withf(|_, payload, _| {
224 let after_value = payload.get("variables").unwrap().get("after").unwrap();
225 after_value.as_str() == Some("secondCursor")
226 })
227 .return_const(Ok(JSON_TEMPLATE
228 .replace("$NEXT", "false")
229 .replace("$CURSOR", "thirdCursor")));
230
231 let result = fetch_project_time_logs_impl(&options, &mock);
232 assert!(result.is_ok());
233 assert_eq!(result.unwrap().name, PROJECT_NAME);
234 }
235
236 #[test]
237 fn fetch_project_not_found() {
238 let input = "https://gitlab.com/invalid/project";
239
240 let options = FetchOptions::new(input, None).unwrap();
241 let mut mock = MockHttpFetcher::new();
242 mock.expect_http_post_request()
243 .return_const(Ok(r#"{"data": {"project": null}}"#.into()));
244
245 let result = fetch_project_time_logs_impl(&options, &mock);
246 assert!(result.is_err());
247 assert!(matches!(
248 result.unwrap_err(),
249 QueryError::ProjectNotFound(_)
250 ));
251 }
252
253 #[test]
254 fn fetch_with_fine_grained_access_token() {
255 const TOKEN: &str = "glpat-fine-grained-access-token";
256 let options = FetchOptions::new(URL, Some(TOKEN.to_string())).unwrap();
257 let mut mock = MockHttpFetcher::new();
258 mock.expect_http_post_request().return_const({
259 Ok(r#"{"errors":[{"message": "Access denied: This operation doesn't support fine-grained personal access tokens.","locations":[{"line": 11, "column": 9}],"path": ["project", "timelogs", "nodes", 0, "spentAt"]}],"data":{"project":null}}"#.into())
260 });
261
262 let result = fetch_project_time_logs_impl(&options, &mock);
263 assert!(result.is_err());
264 assert!(matches!(result.unwrap_err(), QueryError::GraphQlError(_)));
265 }
266}