As a fun hobby project I made my own time logger in Rust called Rusty Time Logger. You can track how many time you spent on a ticket or feature, until now it missed the feature to automatically update the spent time to the scrum board.
In my Github Project I made custom fields for the 'estimated time' and 'time spent'. The goal is that the user will have a button in the app to automatically update the actual time spent to the Github Project Tickets.
This feature is not yet fully implemented but I have already coded the logic to communicate with the Github API. You can find the commit here.
I'd like some intermediary feedback on my code.
- I used the builder pattern to configure the API clients. Have I implemented it correctly?
- I used a trait to implement polymorphism. The trait
PmClient
contains the methodset_spent_time
to update the value to PM platforms like Jira or Github Projects. Is this okay? - Other feedback? Best practices? ...?
Code (or view commit):
PmClient
pub trait PmClient {
async fn set_spent_time(&self, ticket_id: &str, time: &str) -> Result<(), String>;
}
In the future I want to also support other Project Management platforms besides Github Projects like e.g Jira. For each platform I need the method to update the spent time. So this is shared behavior.
GithubClient
use super::{graphql_client::{GraphQLClient, GraphQLQuery}, pm_client::PmClient};
use serde_json::json;
use std::collections::HashMap;
pub struct GitHubClient {
graphql_client: GraphQLClient,
organization: String,
project_number: u8,
}
impl GitHubClient {
pub fn builder() -> GitHubClientBuilder {
GitHubClientBuilder::default()
}
async fn get_project_id(&self) -> Result<String, String> {
let mut variables = HashMap::new();
variables.insert("organization".to_string(), json!(self.organization));
variables.insert("projectNumber".to_string(), json!(self.project_number));
match self.graphql_client.execute(GraphQLQuery::GetProjectId, Some(variables)).await {
Ok(response) => {
let response_json: serde_json::Value = serde_json::from_str(&response)
.map_err(|e| format!("Error parsing response: {}", e))?;
let project_id = response_json
.get("data")
.and_then(|data| data.get("organization"))
.and_then(|org| org.get("projectV2"))
.and_then(|proj| proj.get("id"))
.and_then(|id| id.as_str())
.map(|id| id.to_string())
.ok_or("Project ID not found in response")?;
Ok(project_id)
}
Err(err) => Err(format!("Error executing GraphQL query: {}", err)),
}
}
async fn get_ticket_id(
&self,
project_id: &str,
ticket_number: u64,
) -> Result<String, String> {
let mut variables = HashMap::new();
variables.insert("projectId".to_string(), json!(project_id));
match self.graphql_client.execute(GraphQLQuery::GetTicketId, Some(variables)).await {
Ok(response) => {
let response_json: serde_json::Value = serde_json::from_str(&response)
.map_err(|e| format!("Error parsing response: {}", e))?;
let ticket_id = response_json
.get("data")
.and_then(|data| data.get("node"))
.and_then(|node| node.get("items"))
.and_then(|items| items.get("nodes"))
.and_then(|nodes| {
nodes.as_array()?.iter().find(|ticket| {
ticket.get("content")
.and_then(|content| content.get("number"))
.and_then(|number| number.as_u64())
.map(|num| num == ticket_number)
.unwrap_or(false)
})
})
.and_then(|ticket| ticket.get("id"))
.and_then(|id| id.as_str())
.map(|id| id.to_string())
.ok_or("Ticket ID not found in response")?;
Ok(ticket_id)
}
Err(err) => Err(format!("Error executing GraphQL query: {}", err)),
}
}
async fn get_field_id(
&self,
project_id: &str,
field_name: &str,
) -> Result<String, String> {
let mut variables = HashMap::new();
variables.insert("projectId".to_string(), json!(project_id));
match self.graphql_client.execute(GraphQLQuery::GetFieldId, Some(variables)).await {
Ok(response) => {
let response_json: serde_json::Value = serde_json::from_str(&response)
.map_err(|e| format!("Error parsing response: {}", e))?;
let field_id = response_json
.get("data")
.and_then(|data| data.get("node"))
.and_then(|node| node.get("fields"))
.and_then(|fields| fields.get("nodes"))
.and_then(|nodes| {
nodes.as_array()?.iter().find(|field| {
field.get("name")
.and_then(|name| name.as_str())
.map(|name| name.eq_ignore_ascii_case(field_name))
.unwrap_or(false)
})
})
.and_then(|field| field.get("id"))
.and_then(|id| id.as_str())
.map(|id| id.to_string())
.ok_or("Field ID not found in response")?;
Ok(field_id)
}
Err(err) => Err(format!("Error executing GraphQL query: {}", err)),
}
}
}
impl PmClient for GitHubClient {
async fn set_spent_time(&self, ticket_id: &str, time: &str) -> Result<(), String> {
let project_id = self.get_project_id().await.unwrap();
let mut variables = HashMap::new();
variables.insert("projectId".to_string(), json!(project_id));
variables.insert("itemId".to_string(), json!(self.get_ticket_id(&project_id, ticket_id.parse().unwrap()).await.unwrap()));
variables.insert("fieldId".to_string(), json!(self.get_field_id(&project_id, "spent time").await.unwrap()));
variables.insert("value".to_string(), json!(time));
match self.graphql_client.execute(GraphQLQuery::UpdateSpentTime, Some(variables)).await {
Ok(response) => {
println!("Response: {:?}", response);
Ok(())
}
Err(err) => Err(format!("Error executing GraphQL query: {}", err)),
}
}
}
#[derive(Default)]
pub struct GitHubClientBuilder {
organization: String,
project_number: u8,
auth_token: Option<String>,
}
impl GitHubClientBuilder {
fn graphql_client(&mut self) -> GraphQLClient {
GraphQLClient::builder()
.endpoint("https://api.github.com/graphql")
.client(
reqwest::Client::builder()
.default_headers({
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(reqwest::header::USER_AGENT, "rustytimelogger".parse().unwrap());
headers.insert(reqwest::header::AUTHORIZATION, format!("Bearer {}", self.auth_token.clone().unwrap()).parse().unwrap());
headers
})
.build()
.unwrap()
)
.auth_token(self.auth_token.as_ref().unwrap())
.build().unwrap()
}
pub fn organization(mut self, organization: &str) -> Self {
self.organization = organization.to_string();
self
}
pub fn project_number(mut self, project_number: u8) -> Self {
self.project_number = project_number;
self
}
pub fn auth_token(mut self, auth_token: &str) -> Self {
self.auth_token = Some(auth_token.to_string());
self
}
pub fn build(mut self) -> Result<GitHubClient, &'static str> {
Ok(GitHubClient {
graphql_client: self.graphql_client(),
organization: self.organization,
project_number: self.project_number,
})
}
}
This is the client which implements the PmClient
trait to have the set_spent_time
method. The GithubClient also contains a few private functions to retrieve the correct IDs from the API. These are private functions and not shared because Jira or other platforms maybe use a different structure for their projects/tickets/...
GraphQLClient
use std::collections::HashMap;
use serde_json::json;
#[derive(strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum GraphQLQuery {
GetProjectId,
GetTicketId,
GetFieldId,
UpdateSpentTime,
}
#[derive(Default)]
pub struct GraphQLClient {
endpoint: String,
client: reqwest::Client,
}
impl GraphQLClient {
pub fn builder() -> GraphQLClientBuilder {
GraphQLClientBuilder::default()
}
pub async fn execute(
&self,
query: GraphQLQuery,
variables: Option<HashMap<String, serde_json::Value>>,
) -> Result<String, reqwest::Error> {
let request_body = json!({
"query": Self::load_query(&query.to_string()).unwrap(),
"variables": variables,
});
let response = self
.client
.post(&self.endpoint)
.json(&request_body)
.send()
.await?;
response.text().await
}
fn load_query(query_name: &str) -> Result<String, String> {
std::fs::read_to_string(format!("src/graphql_queries/{}.graphql", query_name))
.map_err(|e| format!("Error reading query file {}: {}", query_name, e))
}
}
#[derive(Default)]
pub struct GraphQLClientBuilder {
endpoint: String,
client: reqwest::Client,
auth_token: Option<String>,
}
impl GraphQLClientBuilder {
pub fn endpoint(mut self, endpoint: &str) -> Self {
self.endpoint = endpoint.to_string();
self
}
pub fn client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self
}
pub fn auth_token(mut self, auth_token: &str) -> Self {
self.auth_token = Some(auth_token.to_string());
self
}
pub fn build(self) -> Result<GraphQLClient, &'static str> {
Ok(GraphQLClient {
endpoint: self.endpoint,
client: self.client,
})
}
}
Github Projects uses a GraphQL API. So I made a client to interact with GraphQL. If Jira uses REST I'd make a RestClient as well.
Question:
This client does not do much besides loading the query and sending the request. Is it maybe possible to put the following logic currenctly in GithubClient into the GraphQLClient?
...
.and_then(|data| data.get("node"))
.and_then(|node| node.get("fields"))
.and_then(|fields| fields.get("nodes"))
...
main.rs
The code to update the spent time is called like this:
let client = GitHubClient::builder()
.auth_token("")
.organization("")
.project_number(1)
.build()
.map_err(|e| e.to_string())?;
let x = client.set_spent_time("11", "rofl").await;
Question: Currently I call GitHubClient
explicitly while this should be a generalized type to also support other platforms in the future. What would be the best way to generalize this? A ClientFactory? Static dispatch? Dynamic dispatch? ...?