From cba0a348127380395f5f3a3f1659615c5f884172 Mon Sep 17 00:00:00 2001 From: Guus van Meerveld Date: Wed, 8 May 2024 22:36:49 +0200 Subject: [PATCH] lib: distinction between auth & unauth client, data structure for publications --- sshn-lib/Cargo.toml | 1 + sshn-lib/queries.graphql | 14 ++-- sshn-lib/src/api/mod.rs | 1 + sshn-lib/src/api/publication.rs | 88 ++++++++++++++++++++++++ sshn-lib/src/client.rs | 114 ++++++++++++++++++-------------- sshn-lib/src/error.rs | 2 + sshn-lib/src/lib.rs | 10 +-- 7 files changed, 169 insertions(+), 61 deletions(-) create mode 100644 sshn-lib/src/api/mod.rs create mode 100644 sshn-lib/src/api/publication.rs diff --git a/sshn-lib/Cargo.toml b/sshn-lib/Cargo.toml index cb53542..a86a211 100644 --- a/sshn-lib/Cargo.toml +++ b/sshn-lib/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-trait = "0.1.80" base64 = "0.22.0" chrono = { version = "0.4.38", features = ["serde"] } digest = "0.10.7" diff --git a/sshn-lib/queries.graphql b/sshn-lib/queries.graphql index 5de2f7b..d27a55d 100644 --- a/sshn-lib/queries.graphql +++ b/sshn-lib/queries.graphql @@ -34,11 +34,10 @@ fragment PublicationListItem on HousingPublication { startTime totalNumberOfApplications - url { - value - } - unit { + complexType { + name + } rentBenefit externalUrl { value @@ -52,11 +51,10 @@ fragment PublicationListItem on HousingPublication { } } } - # allocationProcess { - # ...PublicationListAllocationProcess - # } + applicantSpecific { - allocationChance + numberOfApplicantsWithHigherPriority + is100PercentMatch } } diff --git a/sshn-lib/src/api/mod.rs b/sshn-lib/src/api/mod.rs new file mode 100644 index 0000000..1bfb3be --- /dev/null +++ b/sshn-lib/src/api/mod.rs @@ -0,0 +1 @@ +pub mod publication; diff --git a/sshn-lib/src/api/publication.rs b/sshn-lib/src/api/publication.rs new file mode 100644 index 0000000..0823704 --- /dev/null +++ b/sshn-lib/src/api/publication.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{Error, Result}, + queries::get_publications_list, +}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Publication { + id: String, + name: String, + city: String, + nr_of_applicants: i64, + nr_of_people_with_higher_priority: i64, + is_match: bool, + rent: f64, +} + +impl Publication { + pub fn id(&self) -> &str { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn city(&self) -> &str { + &self.city + } + + pub fn nr_of_applicants(&self) -> i64 { + self.nr_of_applicants + } + + pub fn nr_of_people_with_higher_priority(&self) -> i64 { + self.nr_of_people_with_higher_priority + } + + pub fn is_match(&self) -> bool { + self.is_match + } + + pub fn rent(&self) -> f64 { + self.rent + } +} + +pub fn convert_publications(data: get_publications_list::ResponseData) -> Result> { + Ok(data + .housing_publications + .ok_or(Error::MissingPublications)? + .nodes + .ok_or(Error::MissingPublications)? + .edges + .ok_or(Error::MissingPublications)? + .into_iter() + .filter_map(|publication| { + let publication = publication?.node?; + + let unit = publication.unit.as_ref()?; + let name = unit.complex_type.as_ref()?.name.as_ref()?.to_string(); + let city = unit + .location + .as_ref()? + .city + .as_ref()? + .name + .as_ref()? + .to_string(); + let rent = unit.gross_rent.as_ref()?.exact; + + Some(Publication { + is_match: publication.applicant_specific.as_ref()?.is100_percent_match, + id: publication.id, + name, + city, + nr_of_applicants: publication.total_number_of_applications, + nr_of_people_with_higher_priority: publication + .applicant_specific + .as_ref()? + .number_of_applicants_with_higher_priority + .unwrap_or(0), + rent, + }) + }) + .collect()) +} diff --git a/sshn-lib/src/client.rs b/sshn-lib/src/client.rs index 9c2e293..adac5ff 100644 --- a/sshn-lib/src/client.rs +++ b/sshn-lib/src/client.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; +use async_trait::async_trait; use graphql_client::GraphQLQuery; use serde::{de::DeserializeOwned, Serialize}; use crate::{ constants::{CLIENT_ID, GRAPHQL_URL, LOCALE, REDIRECT_URI, TOKEN_URL}, error::{Error, Result}, + publication::{self, Publication}, queries::{ get_identity_config, get_publications_list, post_application::{self, HousingApplyState}, @@ -14,7 +16,12 @@ use crate::{ tokens::{LoginResponse, Tokens}, }; -pub struct Client { +#[async_trait] +pub trait Client { + async fn get_publications_list(&mut self, max: i64) -> Result>; +} + +pub struct UnAuthenticatedClient { graphql_url: String, http_client: reqwest::Client, } @@ -25,7 +32,7 @@ pub enum LoginType { Password { username: String, password: String }, } -impl Client { +impl UnAuthenticatedClient { pub fn new(graphql_url: Option) -> Self { Self { graphql_url: graphql_url.unwrap_or(GRAPHQL_URL.to_string()), @@ -86,8 +93,6 @@ impl Client { let tokens = self.auth(login_type).await?; let authenticated_client = AuthenticatedClient { - graphql_url: self.graphql_url.clone(), - http_client: reqwest::Client::new(), client: self, tokens, }; @@ -95,6 +100,26 @@ impl Client { Ok(authenticated_client) } + async fn query( + &self, + query: &Q, + access_token: Option<&str>, + ) -> Result { + let mut request = self.http_client.post(&self.graphql_url).json(query); + + if let Some(access_token) = access_token { + request = request.bearer_auth(access_token) + } + + let response = request.send().await?; + + let response = response.error_for_status()?; + + let response_body = response.json::>().await?; + + Ok(response_body.data) + } + pub async fn get_endpoints(&self) -> Result { let variables = get_identity_config::Variables { realm: String::from("sshn"), @@ -102,24 +127,15 @@ impl Client { let request_body = GetIdentityConfig::build_query(variables); - let response = self - .http_client - .post(&self.graphql_url) - .json(&request_body) - .send() - .await?; - - let body = response - .json::>() - .await?; + let data = self.query(&request_body, None).await?; - Ok(body.data) + Ok(data) } +} - pub async fn get_publications_list( - &self, - max: i64, - ) -> Result { +#[async_trait] +impl Client for UnAuthenticatedClient { + async fn get_publications_list(&mut self, max: i64) -> Result> { let variables = get_publications_list::Variables { order_by: Some(get_publications_list::HousingPublicationsOrder::STARTDATE_ASC), first: Some(max), @@ -130,25 +146,16 @@ impl Client { let request_body = GetPublicationsList::build_query(variables); - let response = self - .http_client - .post(&self.graphql_url) - .json(&request_body) - .send() - .await?; + let data: get_publications_list::ResponseData = self.query(&request_body, None).await?; - let body = response - .json::>() - .await?; + let publications = publication::convert_publications(data)?; - Ok(body.data) + Ok(publications) } } pub struct AuthenticatedClient { - graphql_url: String, - http_client: reqwest::Client, - client: Client, + client: UnAuthenticatedClient, tokens: Tokens, } @@ -161,9 +168,7 @@ impl Into for AuthenticatedClient { impl AuthenticatedClient { pub fn new(graphql_url: Option, tokens: Tokens) -> Self { Self { - graphql_url: graphql_url.clone().unwrap_or(GRAPHQL_URL.to_string()), - http_client: reqwest::Client::new(), - client: Client::new(graphql_url), + client: UnAuthenticatedClient::new(graphql_url), tokens, } } @@ -185,26 +190,16 @@ impl AuthenticatedClient { async fn query(&mut self, query: &Q) -> Result { self.check_expiration().await?; - let response = self - .http_client - .post(&self.graphql_url) - .bearer_auth(self.tokens.access_token().as_ref()) - .json(query) - .send() - .await?; - - let response = response.error_for_status()?; - - let response_body = response.json::>().await?; - - Ok(response_body.data) + self.client + .query(query, Some(self.tokens.access_token().content())) + .await } pub fn tokens(&self) -> &Tokens { &self.tokens } - pub fn client(&self) -> &Client { + pub fn client(&self) -> &UnAuthenticatedClient { &self.client } @@ -233,3 +228,24 @@ impl AuthenticatedClient { Ok(()) } } + +#[async_trait] +impl Client for AuthenticatedClient { + async fn get_publications_list(&mut self, max: i64) -> Result> { + let variables = get_publications_list::Variables { + order_by: Some(get_publications_list::HousingPublicationsOrder::STARTDATE_ASC), + first: Some(max), + locale: Some(String::from(LOCALE)), + after: None, + where_: None, + }; + + let request_body = GetPublicationsList::build_query(variables); + + let data: get_publications_list::ResponseData = self.query(&request_body).await?; + + let publications = publication::convert_publications(data)?; + + Ok(publications) + } +} diff --git a/sshn-lib/src/error.rs b/sshn-lib/src/error.rs index be7dae3..1e8c927 100644 --- a/sshn-lib/src/error.rs +++ b/sshn-lib/src/error.rs @@ -12,6 +12,8 @@ pub enum Error { TokenExpired, #[error("Missing refresh token to get new tokens")] MissingRefreshToken, + #[error("SSHN Api did not return valid publications")] + MissingPublications, #[error("The authentication endpoint is missing")] NoAuthUrl, #[error("Failed to parse url: {0}")] diff --git a/sshn-lib/src/lib.rs b/sshn-lib/src/lib.rs index 987d476..7a699b5 100644 --- a/sshn-lib/src/lib.rs +++ b/sshn-lib/src/lib.rs @@ -1,3 +1,4 @@ +mod api; mod client; mod constants; pub mod error; @@ -5,15 +6,16 @@ mod queries; mod tokens; mod utils; +pub use api::*; + pub use { - client::{AuthenticatedClient, Client, LoginType}, + client::{AuthenticatedClient, Client, LoginType, UnAuthenticatedClient}, tokens::{Token, TokenType, Tokens}, utils::{generate_auth_url, get_code_challenge}, }; #[cfg(test)] mod tests { - use chrono::Utc; use crate::client::LoginType; @@ -21,7 +23,7 @@ mod tests { #[tokio::test] async fn test_get_publications() { - let client = Client::new(None); + let client = UnAuthenticatedClient::new(None); let data = client.get_endpoints().await.unwrap(); @@ -35,7 +37,7 @@ mod tests { let username = std::env::var("SSHN_USERNAME").unwrap(); let password = std::env::var("SSHN_PASSWORD").unwrap(); - let client = Client::new(None); + let client = UnAuthenticatedClient::new(None); let _auth_client = client .login(LoginType::Password { username, password })