From 2cabaff305b02259f6b8db73dd8908346c67f50e Mon Sep 17 00:00:00 2001 From: Hannes Herrmann Date: Sat, 24 Aug 2024 13:09:33 +0200 Subject: [PATCH 1/5] feat(cloneable-clients): remove the need for the chained interceptor --- src/api/clients.rs | 321 ++++++++++++++++++++++++++------------------- 1 file changed, 187 insertions(+), 134 deletions(-) diff --git a/src/api/clients.rs b/src/api/clients.rs index 96bc4a2..a569df7 100644 --- a/src/api/clients.rs +++ b/src/api/clients.rs @@ -9,7 +9,6 @@ use custom_error::custom_error; use tonic::codegen::InterceptedService; use tonic::service::Interceptor; use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; -use tonic::{Request, Status}; #[cfg(feature = "interceptors")] use crate::api::interceptors::{AccessTokenInterceptor, ServiceAccountInterceptor}; @@ -45,66 +44,30 @@ custom_error! { TlsInitializationError = "could not setup tls connection", } -#[cfg(feature = "interceptors")] -enum AuthType { - None, - AccessToken(String), - ServiceAccount(ServiceAccount, Option), -} - -/// Helper [Interceptor] that allows chaining of multiple interceptors. -/// This is used to help return the same type in all builder methods like -/// [ClientBuilder::build_management_client]. Otherwise, each interceptor -/// would create its own return type. With this interceptor, the return type -/// stays the same and is not dependent on the authentication type used. -/// The builder can always return `Client>`. -pub struct ChainedInterceptor { - interceptors: Vec>, -} - -impl ChainedInterceptor { - pub(crate) fn new() -> Self { - Self { - interceptors: Vec::new(), - } - } - - #[cfg(feature = "interceptors")] - pub(crate) fn add_interceptor(mut self, interceptor: Box) -> Self { - self.interceptors.push(interceptor); - self - } -} - -impl Interceptor for ChainedInterceptor { - fn call(&mut self, request: Request<()>) -> Result, Status> { - let mut request = request; - for interceptor in &mut self.interceptors { - request = interceptor.call(request)?; - } - Ok(request) - } -} - /// A builder to create configured gRPC clients for ZITADEL API access. /// The builder accepts the api endpoint and (depending on activated features) /// an authentication method. pub struct ClientBuilder { api_endpoint: String, - #[cfg(feature = "interceptors")] - auth_type: AuthType, } +#[cfg(feature = "interceptors")] +pub struct ClientBuilderWithInterceptor { + api_endpoint: String, + interceptor: T, +} + + impl ClientBuilder { /// Create a new client builder with the the provided endpoint. - pub fn new(api_endpoint: &str) -> Self { - Self { + pub fn new(api_endpoint: &str) -> ClientBuilder { + ClientBuilder { api_endpoint: api_endpoint.to_string(), - #[cfg(feature = "interceptors")] - auth_type: AuthType::None, } } +} +impl ClientBuilder { /// Configure the client builder to use a provided access token. /// This can be a pre-fetched token from ZITADEL or some other form /// of a valid access token like a personal access token (PAT). @@ -112,9 +75,11 @@ impl ClientBuilder { /// Clients with this authentication method will have the [`AccessTokenInterceptor`] /// attached. #[cfg(feature = "interceptors")] - pub fn with_access_token(mut self, access_token: &str) -> Self { - self.auth_type = AuthType::AccessToken(access_token.to_string()); - self + pub fn with_access_token(self, access_token: &str) -> ClientBuilderWithInterceptor { + ClientBuilderWithInterceptor { + api_endpoint: self.api_endpoint, + interceptor: AccessTokenInterceptor::new(access_token), + } } /// Configure the client builder to use a [`ServiceAccount`][crate::credentials::ServiceAccount]. @@ -125,14 +90,151 @@ impl ClientBuilder { /// that fetches an access token from ZITADEL and renewes it when it expires. #[cfg(feature = "interceptors")] pub fn with_service_account( - mut self, + self, service_account: &ServiceAccount, auth_options: Option, - ) -> Self { - self.auth_type = AuthType::ServiceAccount(service_account.clone(), auth_options); - self + ) -> ClientBuilderWithInterceptor { + let interceptor = ServiceAccountInterceptor::new( + &self.api_endpoint, + service_account, + auth_options.clone(), + ); + ClientBuilderWithInterceptor { + api_endpoint: self.api_endpoint, + interceptor, + } } +} +impl ClientBuilder { + /// Create a new [`AdminServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-admin-v1")] + pub async fn build_admin_client(self) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(AdminServiceClient::new(channel)) + } + + /// Create a new [`AuthServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-auth-v1")] + pub async fn build_auth_client(self) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(AuthServiceClient::new(channel)) + } + + /// Create a new [`ManagementServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-management-v1")] + pub async fn build_management_client( + self, + ) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(ManagementServiceClient::new(channel)) + } + + /// Create a new [`OidcServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-oidc-v2")] + pub async fn build_oidc_client(self) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(OidcServiceClient::new(channel)) + } + + /// Create a new [`OrganizationServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-org-v2")] + pub async fn build_organization_client( + self, + ) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(OrganizationServiceClient::new(channel)) + } + + /// Create a new [`SessionServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-session-v2")] + pub async fn build_session_client( + self, + ) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(SessionServiceClient::new(channel)) + } + + /// Create a new [`SettingsServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-settings-v2")] + pub async fn build_settings_client( + self, + ) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(SettingsServiceClient::new(channel)) + } + + /// Create a new [`SystemServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-system-v1")] + pub async fn build_system_client(self) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(SystemServiceClient::new(channel)) + } + + /// Create a new [`UserServiceClient`]. + /// + /// ### Errors + /// + /// This function returns a [`ClientError`] if the provided API endpoint + /// cannot be parsed into a valid URL or if the connection to the endpoint + /// is not possible. + #[cfg(feature = "api-user-v2")] + pub async fn build_user_client(self) -> Result, Box> { + let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + Ok(UserServiceClient::new(channel)) + } +} + +#[cfg(feature = "interceptors")] +impl ClientBuilderWithInterceptor { /// Create a new [`AdminServiceClient`]. /// /// Depending on the configured authentication method, the client has @@ -145,13 +247,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-admin-v1")] pub async fn build_admin_client( - &self, - ) -> Result>, Box> - { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(AdminServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -167,13 +268,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-auth-v1")] pub async fn build_auth_client( - &self, - ) -> Result>, Box> - { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(AuthServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -189,15 +289,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-management-v1")] pub async fn build_management_client( - &self, - ) -> Result< - ManagementServiceClient>, - Box, - > { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(ManagementServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -213,13 +310,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-oidc-v2")] pub async fn build_oidc_client( - &self, - ) -> Result>, Box> - { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(OidcServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -235,15 +331,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-org-v2")] pub async fn build_organization_client( - &self, - ) -> Result< - OrganizationServiceClient>, - Box, - > { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(OrganizationServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -259,13 +352,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-session-v2")] pub async fn build_session_client( - &self, - ) -> Result>, Box> - { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(SessionServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -281,15 +373,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-settings-v2")] pub async fn build_settings_client( - &self, - ) -> Result< - SettingsServiceClient>, - Box, - > { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(SettingsServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -305,13 +394,12 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-system-v1")] pub async fn build_system_client( - &self, - ) -> Result>, Box> - { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(SystemServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } @@ -327,38 +415,14 @@ impl ClientBuilder { /// is not possible. #[cfg(feature = "api-user-v2")] pub async fn build_user_client( - &self, - ) -> Result>, Box> - { + self, + ) -> Result>, Box> { let channel = get_channel(&self.api_endpoint).await?; Ok(UserServiceClient::with_interceptor( channel, - self.get_chained_interceptor(), + self.interceptor, )) } - - fn get_chained_interceptor(&self) -> ChainedInterceptor { - #[allow(unused_mut)] - let mut interceptor = ChainedInterceptor::new(); - #[cfg(feature = "interceptors")] - match &self.auth_type { - AuthType::AccessToken(token) => { - interceptor = - interceptor.add_interceptor(Box::new(AccessTokenInterceptor::new(token))); - } - AuthType::ServiceAccount(service_account, auth_options) => { - interceptor = - interceptor.add_interceptor(Box::new(ServiceAccountInterceptor::new( - &self.api_endpoint, - service_account, - auth_options.clone(), - ))); - } - _ => {} - } - - interceptor - } } async fn get_channel(api_endpoint: &str) -> Result { @@ -378,6 +442,7 @@ async fn get_channel(api_endpoint: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use tonic::Request; const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud"; const SERVICE_ACCOUNT: &str = r#" @@ -388,23 +453,11 @@ mod tests { "userId": "181828061098934529" }"#; - #[test] - fn client_builder_without_auth_passes_requests() { - let mut interceptor = ClientBuilder::new(ZITADEL_URL).get_chained_interceptor(); - let request = Request::new(()); - - assert!(request.metadata().is_empty()); - - let request = interceptor.call(request).unwrap(); - - assert!(request.metadata().is_empty()); - } - #[test] fn client_builder_with_access_token_attaches_it() { let mut interceptor = ClientBuilder::new(ZITADEL_URL) .with_access_token("token") - .get_chained_interceptor(); + .interceptor; let request = Request::new(()); assert!(request.metadata().is_empty()); @@ -423,7 +476,7 @@ mod tests { let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(); let mut interceptor = ClientBuilder::new(ZITADEL_URL) .with_service_account(&sa, None) - .get_chained_interceptor(); + .interceptor; let request = Request::new(()); assert!(request.metadata().is_empty()); From d8c00e1adc9f69fb1cbffdd95aeb30b0b591c275 Mon Sep 17 00:00:00 2001 From: Hannes Herrmann Date: Sat, 24 Aug 2024 15:18:36 +0200 Subject: [PATCH 2/5] feat(cloneable-clients): make service account interceptor cloneable --- src/api/clients.rs | 6 +- src/api/interceptors.rs | 156 +++++++++++++++++++++++++++++++++------- 2 files changed, 133 insertions(+), 29 deletions(-) diff --git a/src/api/clients.rs b/src/api/clients.rs index a569df7..04c7ad3 100644 --- a/src/api/clients.rs +++ b/src/api/clients.rs @@ -57,7 +57,6 @@ pub struct ClientBuilderWithInterceptor { interceptor: T, } - impl ClientBuilder { /// Create a new client builder with the the provided endpoint. pub fn new(api_endpoint: &str) -> ClientBuilder { @@ -75,7 +74,10 @@ impl ClientBuilder { /// Clients with this authentication method will have the [`AccessTokenInterceptor`] /// attached. #[cfg(feature = "interceptors")] - pub fn with_access_token(self, access_token: &str) -> ClientBuilderWithInterceptor { + pub fn with_access_token( + self, + access_token: &str, + ) -> ClientBuilderWithInterceptor { ClientBuilderWithInterceptor { api_endpoint: self.api_endpoint, interceptor: AccessTokenInterceptor::new(access_token), diff --git a/src/api/interceptors.rs b/src/api/interceptors.rs index 813b5e1..52f93d9 100644 --- a/src/api/interceptors.rs +++ b/src/api/interceptors.rs @@ -3,6 +3,8 @@ //! interceptors is to authenticate the clients to ZITADEL with //! provided credentials. +use std::ops::Deref; +use std::sync::{Arc, RwLock}; use std::thread; use tokio::runtime::Builder; @@ -41,6 +43,7 @@ use crate::credentials::{AuthenticationOptions, ServiceAccount}; /// # Ok(()) /// # } /// ``` +#[derive(Clone)] pub struct AccessTokenInterceptor { access_token: String, } @@ -125,12 +128,21 @@ impl Interceptor for AccessTokenInterceptor { /// # Ok(()) /// # } /// ``` +#[derive(Clone)] pub struct ServiceAccountInterceptor { + inner: Arc, +} + +struct ServiceAccountInterceptorInner { audience: String, service_account: ServiceAccount, auth_options: AuthenticationOptions, - token: Option, - token_expiry: Option, + state: RwLock>, +} + +struct ServiceAccountInterceptorState { + token: String, + token_expiry: time::OffsetDateTime, } impl ServiceAccountInterceptor { @@ -144,11 +156,12 @@ impl ServiceAccountInterceptor { auth_options: Option, ) -> Self { Self { - audience: audience.to_string(), - service_account: service_account.clone(), - auth_options: auth_options.unwrap_or_default(), - token: None, - token_expiry: None, + inner: Arc::new(ServiceAccountInterceptorInner { + audience: audience.to_string(), + service_account: service_account.clone(), + auth_options: auth_options.unwrap_or_default(), + state: RwLock::new(None), + }), } } } @@ -157,25 +170,32 @@ impl Interceptor for ServiceAccountInterceptor { fn call(&mut self, mut request: tonic::Request<()>) -> Result, Status> { let meta = request.metadata_mut(); if !meta.contains_key("authorization") { - if let Some(token) = &self.token { - if let Some(expiry) = self.token_expiry { - if expiry > time::OffsetDateTime::now_utc() { - meta.insert( - "authorization", - format!("Bearer {}", token).parse().unwrap(), - ); - - return Ok(request); - } + // We unwrap the RWLock to propagate the error if any + // thread panics and the lock is poisoned + let state_read_guard = self.inner.state.read().unwrap(); + + if let Some(ServiceAccountInterceptorState { + token, + token_expiry, + }) = state_read_guard.deref() + { + if token_expiry > &time::OffsetDateTime::now_utc() { + meta.insert( + "authorization", + format!("Bearer {}", token).parse().unwrap(), + ); + + return Ok(request); } } + drop(state_read_guard); - let aud = self.audience.clone(); - let auth = self.auth_options.clone(); - let sa = self.service_account.clone(); + let aud = self.inner.audience.clone(); + let auth = self.inner.auth_options.clone(); + let sa = self.inner.service_account.clone(); let token = thread::spawn(move || { - let rt = Builder::new_multi_thread().enable_all().build().unwrap(); + let rt = Builder::new_current_thread().enable_all().build().unwrap(); rt.block_on(async { sa.authenticate_with_options(&aud, &auth) .await @@ -187,8 +207,14 @@ impl Interceptor for ServiceAccountInterceptor { .join() .map_err(|_| Status::internal("could not fetch token"))??; - self.token = Some(token.clone()); - self.token_expiry = Some(time::OffsetDateTime::now_utc() + time::Duration::minutes(59)); + // We unwrap the RWLock to propagate the error if any + // thread panics and the lock is poisoned + let mut state_write_guard = self.inner.state.write().unwrap(); + + *state_write_guard = Some(ServiceAccountInterceptorState { + token: token.clone(), + token_expiry: time::OffsetDateTime::now_utc() + time::Duration::minutes(59), + }); meta.insert( "authorization", @@ -288,6 +314,46 @@ mod tests { .is_empty()); } + #[test] + fn service_account_interceptor_can_be_cloned_and_shares_token_sync_context() { + let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(); + let mut interceptor = ServiceAccountInterceptor::new(ZITADEL_URL, &sa, None); + let mut second_interceptor = interceptor.clone(); + let request = Request::new(()); + let second_request = Request::new(()); + + assert!(request.metadata().is_empty()); + assert!(second_request.metadata().is_empty()); + + let request = interceptor.call(request).unwrap(); + let second_request = second_interceptor.call(second_request).unwrap(); + + assert_eq!( + request.metadata().get("authorization"), + second_request.metadata().get("authorization") + ); + } + + #[tokio::test] + async fn service_account_interceptor_can_be_cloned_and_shares_token_async_context() { + let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(); + let mut interceptor = ServiceAccountInterceptor::new(ZITADEL_URL, &sa, None); + let mut second_interceptor = interceptor.clone(); + let request = Request::new(()); + let second_request = Request::new(()); + + assert!(request.metadata().is_empty()); + assert!(second_request.metadata().is_empty()); + + let request = interceptor.call(request).unwrap(); + let second_request = second_interceptor.call(second_request).unwrap(); + + assert_eq!( + request.metadata().get("authorization"), + second_request.metadata().get("authorization") + ); + } + #[test] fn service_account_interceptor_ignore_existing_auth_metadata_sync_context() { let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(); @@ -333,10 +399,28 @@ mod tests { let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(); let mut interceptor = ServiceAccountInterceptor::new(ZITADEL_URL, &sa, None); interceptor.call(Request::new(())).unwrap(); - let token = interceptor.token.clone().unwrap(); + let token = interceptor + .inner + .state + .read() + .unwrap() + .as_ref() + .unwrap() + .token + .clone(); interceptor.call(Request::new(())).unwrap(); - assert_eq!(token, interceptor.token.unwrap()); + assert_eq!( + token, + interceptor + .inner + .state + .read() + .unwrap() + .as_ref() + .unwrap() + .token + ); } #[tokio::test] @@ -344,9 +428,27 @@ mod tests { let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(); let mut interceptor = ServiceAccountInterceptor::new(ZITADEL_URL, &sa, None); interceptor.call(Request::new(())).unwrap(); - let token = interceptor.token.clone().unwrap(); + let token = interceptor + .inner + .state + .read() + .unwrap() + .as_ref() + .unwrap() + .token + .clone(); interceptor.call(Request::new(())).unwrap(); - assert_eq!(token, interceptor.token.unwrap()); + assert_eq!( + token, + interceptor + .inner + .state + .read() + .unwrap() + .as_ref() + .unwrap() + .token + ); } } From 29fc5f462be471ea2168fbb31a8338c43af1ab96 Mon Sep 17 00:00:00 2001 From: Hannes Herrmann Date: Mon, 26 Aug 2024 20:50:16 +0200 Subject: [PATCH 3/5] feat(clonable-clients): simplify client builder --- src/api/clients.rs | 311 ++++++++++++--------------------------------- 1 file changed, 82 insertions(+), 229 deletions(-) diff --git a/src/api/clients.rs b/src/api/clients.rs index 04c7ad3..9b1615d 100644 --- a/src/api/clients.rs +++ b/src/api/clients.rs @@ -6,7 +6,7 @@ use std::error::Error; use custom_error::custom_error; -use tonic::codegen::InterceptedService; +use tonic::codegen::{Body, Bytes, InterceptedService, StdError}; use tonic::service::Interceptor; use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; @@ -47,26 +47,44 @@ custom_error! { /// A builder to create configured gRPC clients for ZITADEL API access. /// The builder accepts the api endpoint and (depending on activated features) /// an authentication method. -pub struct ClientBuilder { +pub struct ClientBuilder { api_endpoint: String, + interceptor: T, } -#[cfg(feature = "interceptors")] -pub struct ClientBuilderWithInterceptor { - api_endpoint: String, - interceptor: T, +pub trait BuildInterceptedService { + type Target; + fn build_service(self, channel: Channel) -> Self::Target; } -impl ClientBuilder { - /// Create a new client builder with the the provided endpoint. - pub fn new(api_endpoint: &str) -> ClientBuilder { +pub struct NoInterceptor; + +impl BuildInterceptedService for NoInterceptor { + type Target = Channel; + fn build_service(self, channel: Channel) -> Self::Target { + channel + } +} + +impl BuildInterceptedService for T +where + T: Interceptor, +{ + type Target = InterceptedService; + fn build_service(self, channel: Channel) -> Self::Target { + InterceptedService::new(channel, self) + } +} + +impl ClientBuilder { + /// Create a new client builder with the provided endpoint. + pub fn new(api_endpoint: &str) -> ClientBuilder { ClientBuilder { api_endpoint: api_endpoint.to_string(), + interceptor: NoInterceptor, } } -} -impl ClientBuilder { /// Configure the client builder to use a provided access token. /// This can be a pre-fetched token from ZITADEL or some other form /// of a valid access token like a personal access token (PAT). @@ -74,11 +92,8 @@ impl ClientBuilder { /// Clients with this authentication method will have the [`AccessTokenInterceptor`] /// attached. #[cfg(feature = "interceptors")] - pub fn with_access_token( - self, - access_token: &str, - ) -> ClientBuilderWithInterceptor { - ClientBuilderWithInterceptor { + pub fn with_access_token(self, access_token: &str) -> ClientBuilder { + ClientBuilder { api_endpoint: self.api_endpoint, interceptor: AccessTokenInterceptor::new(access_token), } @@ -95,20 +110,28 @@ impl ClientBuilder { self, service_account: &ServiceAccount, auth_options: Option, - ) -> ClientBuilderWithInterceptor { + ) -> ClientBuilder { let interceptor = ServiceAccountInterceptor::new( &self.api_endpoint, service_account, auth_options.clone(), ); - ClientBuilderWithInterceptor { + ClientBuilder { api_endpoint: self.api_endpoint, interceptor, } } } -impl ClientBuilder { +impl ClientBuilder +where + T: BuildInterceptedService, + T::Target: tonic::client::GrpcService, + >::ResponseBody: + Body + Send + 'static, + <>::ResponseBody as Body>::Error: + Into + Send, +{ /// Create a new [`AdminServiceClient`]. /// /// ### Errors @@ -117,8 +140,10 @@ impl ClientBuilder { /// cannot be parsed into a valid URL or if the connection to the endpoint /// is not possible. #[cfg(feature = "api-admin-v1")] - pub async fn build_admin_client(self) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + pub async fn build_admin_client(self) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); Ok(AdminServiceClient::new(channel)) } @@ -130,8 +155,10 @@ impl ClientBuilder { /// cannot be parsed into a valid URL or if the connection to the endpoint /// is not possible. #[cfg(feature = "api-auth-v1")] - pub async fn build_auth_client(self) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + pub async fn build_auth_client(self) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); Ok(AuthServiceClient::new(channel)) } @@ -145,8 +172,10 @@ impl ClientBuilder { #[cfg(feature = "api-management-v1")] pub async fn build_management_client( self, - ) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + ) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); Ok(ManagementServiceClient::new(channel)) } @@ -158,8 +187,10 @@ impl ClientBuilder { /// cannot be parsed into a valid URL or if the connection to the endpoint /// is not possible. #[cfg(feature = "api-oidc-v2")] - pub async fn build_oidc_client(self) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + pub async fn build_oidc_client(self) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); Ok(OidcServiceClient::new(channel)) } @@ -173,8 +204,10 @@ impl ClientBuilder { #[cfg(feature = "api-org-v2")] pub async fn build_organization_client( self, - ) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + ) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); Ok(OrganizationServiceClient::new(channel)) } @@ -188,8 +221,10 @@ impl ClientBuilder { #[cfg(feature = "api-session-v2")] pub async fn build_session_client( self, - ) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + ) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); Ok(SessionServiceClient::new(channel)) } @@ -203,8 +238,10 @@ impl ClientBuilder { #[cfg(feature = "api-settings-v2")] pub async fn build_settings_client( self, - ) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; + ) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); Ok(SettingsServiceClient::new(channel)) } @@ -216,214 +253,28 @@ impl ClientBuilder { /// cannot be parsed into a valid URL or if the connection to the endpoint /// is not possible. #[cfg(feature = "api-system-v1")] - pub async fn build_system_client(self) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; - Ok(SystemServiceClient::new(channel)) - } - - /// Create a new [`UserServiceClient`]. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-user-v2")] - pub async fn build_user_client(self) -> Result, Box> { - let channel = crate::api::clients::get_channel(&self.api_endpoint).await?; - Ok(UserServiceClient::new(channel)) - } -} - -#[cfg(feature = "interceptors")] -impl ClientBuilderWithInterceptor { - /// Create a new [`AdminServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-admin-v1")] - pub async fn build_admin_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(AdminServiceClient::with_interceptor( - channel, - self.interceptor, - )) - } - - /// Create a new [`AuthServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-auth-v1")] - pub async fn build_auth_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(AuthServiceClient::with_interceptor( - channel, - self.interceptor, - )) - } - - /// Create a new [`ManagementServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-management-v1")] - pub async fn build_management_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(ManagementServiceClient::with_interceptor( - channel, - self.interceptor, - )) - } - - /// Create a new [`OidcServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-oidc-v2")] - pub async fn build_oidc_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(OidcServiceClient::with_interceptor( - channel, - self.interceptor, - )) - } - - /// Create a new [`OrganizationServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-org-v2")] - pub async fn build_organization_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(OrganizationServiceClient::with_interceptor( - channel, - self.interceptor, - )) - } - - /// Create a new [`SessionServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-session-v2")] - pub async fn build_session_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(SessionServiceClient::with_interceptor( - channel, - self.interceptor, - )) - } - - /// Create a new [`SettingsServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-settings-v2")] - pub async fn build_settings_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(SettingsServiceClient::with_interceptor( - channel, - self.interceptor, - )) - } - - /// Create a new [`SystemServiceClient`]. - /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// - /// ### Errors - /// - /// This function returns a [`ClientError`] if the provided API endpoint - /// cannot be parsed into a valid URL or if the connection to the endpoint - /// is not possible. - #[cfg(feature = "api-system-v1")] pub async fn build_system_client( self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(SystemServiceClient::with_interceptor( - channel, - self.interceptor, - )) + ) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); + Ok(SystemServiceClient::new(channel)) } /// Create a new [`UserServiceClient`]. /// - /// Depending on the configured authentication method, the client has - /// specialised interceptors attached. - /// /// ### Errors /// /// This function returns a [`ClientError`] if the provided API endpoint /// cannot be parsed into a valid URL or if the connection to the endpoint /// is not possible. #[cfg(feature = "api-user-v2")] - pub async fn build_user_client( - self, - ) -> Result>, Box> { - let channel = get_channel(&self.api_endpoint).await?; - Ok(UserServiceClient::with_interceptor( - channel, - self.interceptor, - )) + pub async fn build_user_client(self) -> Result, Box> { + let channel = self + .interceptor + .build_service(get_channel(&self.api_endpoint).await?); + Ok(UserServiceClient::new(channel)) } } @@ -460,6 +311,7 @@ mod tests { let mut interceptor = ClientBuilder::new(ZITADEL_URL) .with_access_token("token") .interceptor; + let request = Request::new(()); assert!(request.metadata().is_empty()); @@ -479,6 +331,7 @@ mod tests { let mut interceptor = ClientBuilder::new(ZITADEL_URL) .with_service_account(&sa, None) .interceptor; + let request = Request::new(()); assert!(request.metadata().is_empty()); From c53cf556c8b61cf4a02b080f74391dc3f9638c23 Mon Sep 17 00:00:00 2001 From: Hannes Herrmann Date: Tue, 27 Aug 2024 00:09:37 +0200 Subject: [PATCH 4/5] feat: allow to inject custom interceptors --- src/api/clients.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/api/clients.rs b/src/api/clients.rs index 9b1615d..07973a8 100644 --- a/src/api/clients.rs +++ b/src/api/clients.rs @@ -85,6 +85,17 @@ impl ClientBuilder { } } + /// Configure the client builder to inject a custom interceptor, + /// which can be used to modify the [Request][tonic::request::Request] before being sent. + /// + /// See [Interceptor][tonic::service::Interceptor] for more details. + pub fn with_interceptor(self, interceptor: I) -> ClientBuilder { + ClientBuilder { + api_endpoint: self.api_endpoint, + interceptor + } + } + /// Configure the client builder to use a provided access token. /// This can be a pre-fetched token from ZITADEL or some other form /// of a valid access token like a personal access token (PAT). @@ -93,10 +104,7 @@ impl ClientBuilder { /// attached. #[cfg(feature = "interceptors")] pub fn with_access_token(self, access_token: &str) -> ClientBuilder { - ClientBuilder { - api_endpoint: self.api_endpoint, - interceptor: AccessTokenInterceptor::new(access_token), - } + self.with_interceptor(AccessTokenInterceptor::new(access_token)) } /// Configure the client builder to use a [`ServiceAccount`][crate::credentials::ServiceAccount]. @@ -116,10 +124,7 @@ impl ClientBuilder { service_account, auth_options.clone(), ); - ClientBuilder { - api_endpoint: self.api_endpoint, - interceptor, - } + self.with_interceptor(interceptor) } } From 7900c85231d160376d553b16c28d56a1b68b4fc5 Mon Sep 17 00:00:00 2001 From: Hannes Herrmann Date: Thu, 29 Aug 2024 13:51:16 +0200 Subject: [PATCH 5/5] feat(cloneable-clients): verify clients are cloneable --- src/api/clients.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/api/clients.rs b/src/api/clients.rs index 07973a8..d68a538 100644 --- a/src/api/clients.rs +++ b/src/api/clients.rs @@ -92,7 +92,7 @@ impl ClientBuilder { pub fn with_interceptor(self, interceptor: I) -> ClientBuilder { ClientBuilder { api_endpoint: self.api_endpoint, - interceptor + interceptor, } } @@ -311,6 +311,27 @@ mod tests { "userId": "181828061098934529" }"#; + #[tokio::test] + async fn clients_are_cloneable() { + let access_token_client = ClientBuilder::new(ZITADEL_URL) + .with_access_token("token") + .build_user_client() + .await + .unwrap(); + let _cloned = access_token_client.clone(); + + let service_account_client = ClientBuilder::new(ZITADEL_URL) + .with_service_account( + &ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap(), + None, + ) + .build_user_client() + .await + .unwrap(); + + let _cloned = service_account_client.clone(); + } + #[test] fn client_builder_with_access_token_attaches_it() { let mut interceptor = ClientBuilder::new(ZITADEL_URL)