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 = model.data.project.ok_or_else(|| match &model.errors {
88 Some(errors) => QueryError::GraphQlError(errors.errors[0].message.clone()),
90 None => QueryError::ProjectNotFound(format!("{}/{}", options.host, options.path)),
92 })?;
93
94 has_next_page = project.timelogs.page_info.has_next_page;
96 cursor = project.timelogs.page_info.end_cursor;
97
98 if !has_next_page {
100 name = project.name;
101 total_spent_time = project.timelogs.total_spent_time;
102 }
103
104 time_logs.extend(project.timelogs.nodes);
106 }
107
108 Ok(Project {
109 name,
110 time_logs,
111 total_spent_time,
112 })
113}
114
115fn build_query_payload(
117 template: &str,
118 project_path: &str,
119 cursor: Option<&str>,
120) -> serde_json::Value {
121 let variables = match cursor {
122 Some(c) => json!({
123 "projectPath": project_path,
124 "after": c,
125 }),
126 None => json!({
127 "projectPath": project_path,
128 "after": null,
129 }),
130 };
131
132 json!({
133 "query": template,
134 "variables": variables,
135 })
136}
137
138#[derive(Debug, Error)]
140pub enum QueryError {
141 #[error("A network error has occurred: {0}")]
143 NetworkError(NetworkError),
144 #[error("Project '{0}' not found")]
146 ProjectNotFound(String),
147 #[error("Error with the GraphQL query: {0}")]
149 GraphQlError(String),
150 #[error("Could not deserialize response: {0}")]
152 JsonParseError(#[from] serde_path_to_error::Error<Error>),
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::fetch_api::http_requests::MockHttpFetcher;
159
160 #[test]
161 fn fetch_project_correctly() {
162 let input = "https://gitlab.ost.ch/test-user/test-project";
163 let output = "Test".to_string();
164
165 let options = FetchOptions::new(input, None).unwrap();
166 let mut mock = MockHttpFetcher::new();
167 mock.expect_http_post_request().return_const({
168 Ok(r#"{"data": { "project": { "name": "Test", "timelogs": {"pageInfo": {"hasNextPage": false, "endCursor": null}, "totalSpentTime": "20", "nodes": []}}}}"#.into())
169 });
170
171 let result = fetch_project_time_logs_impl(&options, &mock);
172 assert!(result.is_ok());
173 assert_eq!(result.unwrap().name, output);
174 }
175
176 #[test]
177 fn fetch_project_with_pagination() {
178 const JSON_TEMPLATE: &str = r#"{"data":{"project":{"name":"Test","timelogs":{"pageInfo":{"hasNextPage":$NEXT,"endCursor":"$CURSOR"}, "totalSpentTime": "20", "nodes":[]}}}}"#;
179
180 let input = "https://gitlab.ost.ch/test-user/test-project";
181 let output = "Test".to_string();
182
183 let options = FetchOptions::new(input, None).unwrap();
184 let mut mock = MockHttpFetcher::new();
185
186 mock.expect_http_post_request()
188 .times(1) .withf(|_, payload, _| {
190 let after_value = payload.get("variables").unwrap().get("after").unwrap();
191 after_value.is_null()
192 })
193 .return_const(Ok(JSON_TEMPLATE
194 .replace("$NEXT", "true")
195 .replace("$CURSOR", "firstCursor")));
196
197 mock.expect_http_post_request()
199 .times(1)
200 .withf(|_, payload, _| {
201 let after_value = payload.get("variables").unwrap().get("after").unwrap();
202 after_value.as_str() == Some("firstCursor")
203 })
204 .return_const(Ok(JSON_TEMPLATE
205 .replace("$NEXT", "true")
206 .replace("$CURSOR", "secondCursor")));
207
208 mock.expect_http_post_request()
210 .times(1)
211 .withf(|_, payload, _| {
212 let after_value = payload.get("variables").unwrap().get("after").unwrap();
213 after_value.as_str() == Some("secondCursor")
214 })
215 .return_const(Ok(JSON_TEMPLATE
216 .replace("$NEXT", "false")
217 .replace("$CURSOR", "thirdCursor")));
218
219 let result = fetch_project_time_logs_impl(&options, &mock);
220 assert!(result.is_ok());
221 assert_eq!(result.unwrap().name, output);
222 }
223
224 #[test]
225 fn fetch_project_not_found() {
226 let input = "https://gitlab.com/invalid/project";
227
228 let options = FetchOptions::new(input, None).unwrap();
229 let mut mock = MockHttpFetcher::new();
230 mock.expect_http_post_request()
231 .return_const(Ok(r#"{"data": {"project": null}}"#.into()));
232
233 let result = fetch_project_time_logs_impl(&options, &mock);
234 assert!(result.is_err());
235 assert!(matches!(
236 result.unwrap_err(),
237 QueryError::ProjectNotFound(_)
238 ));
239 }
240}