GraphQL client to interact with Github Projects

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 method set_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? ...?

I am bumping this thread because I did not get a review yet, but I would really like one to become a better and professional Rust-developer instead of a remaining on hobby-coder level.

Just a few suggestions would be really appreciated, I am not asking for a detailed analysis of my code. Thanks!

Here's some quick feedback:

  • Don't use unwrap inside of functions that return Result.
  • The build methods on your builders are infallible. Don't return Result, you are sending the wrong information to whoever reads their signature.
  • Don't return &'static str.
  • Don't store sensible data as a plain string (the auth token).
  • Don't use Option for non-nullable fields.
1 Like

Thanks for the feedback! I will implement it for sure.

  • What do you think about the overall project structure?
  • Is the code in general readable?

Why are the build methods infallible? What if the user does not configure enough properties and generates a wrong client? Or should it be considered infallible because of the default-trait?

In general, it looks fine. Conceptually, though, I don't like some details such as having Github-specific abstractions leaking to the underlying GraphQL client abstraction. This defeats the purpose of having them as separate abstractions in the first place.

If you are planning to accept multiple client implementations, the best approach would be to create a trait to abstract away the behaviour, and have specific implementations such as GraphQL or REST.

You never return any errors from them.