gitlab_time_report/fetch_api/
mod.rs

1//! Fetches time logs and related data from the GitLab API.
2
3mod 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
17/// Runs a query against the GitLab API and returns the response as a string. Parsing the response
18/// is up to the caller.
19/// If there is an error, the function returns a [`QueryError`].
20/// # Parameter
21/// `payload`: A valid GraphQL JSON payload
22/// `client`: The HTTP client, usually `reqwest::Client`
23/// `fetch_options`: The options specified by the user
24fn 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    // Turn the NetworkError into a QueryError if one occurred
36    response.map_err(QueryError::NetworkError)
37}
38
39/// Fetches the project time logs from the GitLab API.
40/// A valid access token is required for internal and private projects.
41/// To call the function, create a `FetchOptions` instance with [`FetchOptions::new()`].
42/// # Errors
43/// For the possible errors, see [`QueryError`].
44/// # Example
45/// ```
46/// # use gitlab_time_report::{fetch_project_time_logs, FetchOptions};
47/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
48/// let options = FetchOptions::new("https://gitlab.com/gitlab-org/gitlab", None)?;
49/// let project = fetch_project_time_logs(&options);
50/// // Check for errors
51/// match project {
52///     Ok(project) => println!("{:?}", project),
53///     Err(err) => println!("{:?}", err),
54/// };
55/// # Ok(()) }
56/// ```
57#[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
63/// Implementation of [`fetch_project_time_logs()`] that takes `FetchOptions` and an HTTP client as parameter.
64fn 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    // Fetch all pages from the GitLab API
77    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        // Create a new deserializer that reads the response
82        let deserializer = &mut serde_json::Deserializer::from_str(&response);
83        // Run the deserializer. If everything is okay, the result is saved into the model variable
84        // Use serde_path_to_error to get the field where the deserialization failed.
85        let model: ApiResponse = serde_path_to_error::deserialize(deserializer)?;
86
87        let project = model.data.project.ok_or_else(|| match &model.errors {
88            // A GraphQL error occurred.
89            Some(errors) => QueryError::GraphQlError(errors.errors[0].message.clone()),
90            // The API returned `"project":null`, the project doesn't exist or has been accessed without a valid access token.
91            None => QueryError::ProjectNotFound(format!("{}/{}", options.host, options.path)),
92        })?;
93
94        // Update the pagination information
95        has_next_page = project.timelogs.page_info.has_next_page;
96        cursor = project.timelogs.page_info.end_cursor;
97
98        // Store the project name and total_spent_time from the last page in the response.
99        if !has_next_page {
100            name = project.name;
101            total_spent_time = project.timelogs.total_spent_time;
102        }
103
104        // Accumulate timelogs
105        time_logs.extend(project.timelogs.nodes);
106    }
107
108    Ok(Project {
109        name,
110        time_logs,
111        total_spent_time,
112    })
113}
114
115/// Builds a GraphQL query with the given project path and optional cursor for pagination.
116fn 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/// Errors that can occur during an API query.
139#[derive(Debug, Error)]
140pub enum QueryError {
141    /// A network error has occurred during the API call.
142    #[error("A network error has occurred: {0}")]
143    NetworkError(NetworkError),
144    /// API returns `"project":null`, project does not exist or has been accessed without a valid access token.
145    #[error("Project '{0}' not found")]
146    ProjectNotFound(String),
147    /// The GraphQL query returned an error, i.e. incorrect syntax, invalid query.
148    #[error("Error with the GraphQL query: {0}")]
149    GraphQlError(String),
150    /// The response could not be deserialized into the model structs.
151    #[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 call when returning the first page
187        mock.expect_http_post_request()
188            .times(1) // Should be called once
189            .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        // Second page
198        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        // Third and final page
209        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}