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 = validate_model(model, options)?;
88
89        // Update the pagination information
90        has_next_page = project.timelogs.page_info.has_next_page;
91        cursor = project.timelogs.page_info.end_cursor;
92
93        // Store the project name and total_spent_time from the last page in the response.
94        if !has_next_page {
95            name = project.name;
96            total_spent_time = project.timelogs.total_spent_time;
97        }
98
99        // Accumulate timelogs
100        time_logs.extend(project.timelogs.nodes);
101    }
102
103    // Remove the `Option` from all time logs. There should be no `None` anyway.
104    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
113/// Builds a GraphQL query with the given project path and optional cursor for pagination.
114fn 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
136/// Validates the response from the GitLab API by checking for GraphQL errors, and if the project can be accessed.
137fn validate_model(
138    model: ApiResponse,
139    options: &FetchOptions,
140) -> Result<api_model::Project, QueryError> {
141    // Check for GraphQL errors. If there are any, return the first one.
142    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        // The API returned `"project":null`, the project doesn't exist or has been accessed without a valid access token.
148        QueryError::ProjectNotFound(format!("{}/{}", options.host, options.path))
149    })?;
150    Ok(project)
151}
152
153/// Errors that can occur during an API query.
154#[derive(Debug, Error)]
155pub enum QueryError {
156    /// A network error has occurred during the API call.
157    #[error("A network error has occurred: {0}")]
158    NetworkError(NetworkError),
159    /// API returns `"project":null`, project does not exist or has been accessed without a valid access token.
160    #[error("Project '{0}' not found")]
161    ProjectNotFound(String),
162    /// The GraphQL query returned an error, i.e. incorrect syntax, invalid query.
163    #[error("Error with the GraphQL query: {0}")]
164    GraphQlError(String),
165    /// The response could not be deserialized into the model structs.
166    #[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 call when returning the first page
199        mock.expect_http_post_request()
200            .times(1) // Should be called once
201            .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        // Second page
210        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        // Third and final page
221        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}