mod api_model;
mod deserializer;
mod fetch_options;
mod http_requests;
use crate::fetch_api::api_model::ApiResponse;
use crate::fetch_api::http_requests::NetworkError;
use crate::model::Project;
use chrono::Duration;
pub use fetch_options::FetchOptions;
use reqwest::blocking::Client;
use serde_json::{Error, json};
use thiserror::Error;
fn run_query(
payload: serde_json::Value,
client: &impl http_requests::HttpFetcher,
fetch_options: &FetchOptions,
) -> Result<String, QueryError> {
let url = format!(
"{}://{}/api/graphql",
fetch_options.protocol, fetch_options.host
);
let response = client.http_post_request(&url, payload, fetch_options);
response.map_err(QueryError::NetworkError)
}
#[cfg(not(tarpaulin_include))]
pub fn fetch_project_time_logs(options: &FetchOptions) -> Result<Project, QueryError> {
let http_client = Client::new();
fetch_project_time_logs_impl(options, &http_client)
}
fn fetch_project_time_logs_impl(
options: &FetchOptions,
http_client: &impl http_requests::HttpFetcher,
) -> Result<Project, QueryError> {
let query_template = include_str!("query_project_time_logs.graphql");
let mut time_logs = Vec::new();
let mut name = String::new();
let mut total_spent_time = Duration::default();
let mut cursor: Option<String> = None;
let mut has_next_page = true;
while has_next_page {
let payload = build_query_payload(query_template, &options.path, cursor.as_deref());
let response = run_query(payload, http_client, options)?;
let deserializer = &mut serde_json::Deserializer::from_str(&response);
let model: ApiResponse = serde_path_to_error::deserialize(deserializer)?;
let project = model.data.project.ok_or_else(|| match &model.errors {
Some(errors) => QueryError::GraphQlError(errors.errors[0].message.clone()),
None => QueryError::ProjectNotFound(format!("{}/{}", options.host, options.path)),
})?;
has_next_page = project.timelogs.page_info.has_next_page;
cursor = project.timelogs.page_info.end_cursor;
if !has_next_page {
name = project.name;
total_spent_time = project.timelogs.total_spent_time;
}
time_logs.extend(project.timelogs.nodes);
}
Ok(Project {
name,
time_logs,
total_spent_time,
})
}
fn build_query_payload(
template: &str,
project_path: &str,
cursor: Option<&str>,
) -> serde_json::Value {
let variables = match cursor {
Some(c) => json!({
"projectPath": project_path,
"after": c,
}),
None => json!({
"projectPath": project_path,
"after": null,
}),
};
json!({
"query": template,
"variables": variables,
})
}
#[derive(Debug, Error)]
pub enum QueryError {
#[error("A network error has occurred: {0}")]
NetworkError(NetworkError),
#[error("Project '{0}' not found")]
ProjectNotFound(String),
#[error("Error with the GraphQL query: {0}")]
GraphQlError(String),
#[error("Could not deserialize response: {0}")]
JsonParseError(#[from] serde_path_to_error::Error<Error>),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fetch_api::http_requests::MockHttpFetcher;
#[test]
fn fetch_project_correctly() {
let input = "https://gitlab.ost.ch/test-user/test-project";
let output = "Test".to_string();
let options = FetchOptions::new(input, None).unwrap();
let mut mock = MockHttpFetcher::new();
mock.expect_http_post_request().return_const({
Ok(r#"{"data": { "project": { "name": "Test", "timelogs": {"pageInfo": {"hasNextPage": false, "endCursor": null}, "totalSpentTime": "20", "nodes": []}}}}"#.into())
});
let result = fetch_project_time_logs_impl(&options, &mock);
assert!(result.is_ok());
assert_eq!(result.unwrap().name, output);
}
#[test]
fn fetch_project_with_pagination() {
const JSON_TEMPLATE: &str = r#"{"data":{"project":{"name":"Test","timelogs":{"pageInfo":{"hasNextPage":$NEXT,"endCursor":"$CURSOR"}, "totalSpentTime": "20", "nodes":[]}}}}"#;
let input = "https://gitlab.ost.ch/test-user/test-project";
let output = "Test".to_string();
let options = FetchOptions::new(input, None).unwrap();
let mut mock = MockHttpFetcher::new();
mock.expect_http_post_request()
.times(1) .withf(|_, payload, _| {
let after_value = payload.get("variables").unwrap().get("after").unwrap();
after_value.is_null()
})
.return_const(Ok(JSON_TEMPLATE
.replace("$NEXT", "true")
.replace("$CURSOR", "firstCursor")));
mock.expect_http_post_request()
.times(1)
.withf(|_, payload, _| {
let after_value = payload.get("variables").unwrap().get("after").unwrap();
after_value.as_str() == Some("firstCursor")
})
.return_const(Ok(JSON_TEMPLATE
.replace("$NEXT", "true")
.replace("$CURSOR", "secondCursor")));
mock.expect_http_post_request()
.times(1)
.withf(|_, payload, _| {
let after_value = payload.get("variables").unwrap().get("after").unwrap();
after_value.as_str() == Some("secondCursor")
})
.return_const(Ok(JSON_TEMPLATE
.replace("$NEXT", "false")
.replace("$CURSOR", "thirdCursor")));
let result = fetch_project_time_logs_impl(&options, &mock);
assert!(result.is_ok());
assert_eq!(result.unwrap().name, output);
}
#[test]
fn fetch_project_not_found() {
let input = "https://gitlab.com/invalid/project";
let options = FetchOptions::new(input, None).unwrap();
let mut mock = MockHttpFetcher::new();
mock.expect_http_post_request()
.return_const(Ok(r#"{"data": {"project": null}}"#.into()));
let result = fetch_project_time_logs_impl(&options, &mock);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
QueryError::ProjectNotFound(_)
));
}
}