hinge-rs 0.1.1

Unofficial typed Hinge API client for Rust, with REST, Sendbird chat, and generated OpenAPI docs.
Documentation
use super::{
    AuthApi, ChatApi, ConnectionsApi, LikesApi, PersistenceApi, ProfilesApi, PromptsApi,
    RatingsApi, RawApi, RecommendationsApi, SettingsApi,
};
use crate::client::{DEFAULT_PUBLIC_IDS_BATCH_SIZE, HingeClient, RecsFetchConfig};
use crate::errors::HingeError;
use crate::settings::Settings;
use crate::storage::{FsStorage, SecretStore, Storage};
use secrecy::SecretString;
use std::sync::Arc;

#[derive(Clone, Debug)]
pub struct Config {
    pub settings: Settings,
    pub recs_fetch_config: RecsFetchConfig,
    pub public_ids_batch_size: usize,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            settings: Settings::default(),
            recs_fetch_config: RecsFetchConfig::default(),
            public_ids_batch_size: DEFAULT_PUBLIC_IDS_BATCH_SIZE,
        }
    }
}

#[derive(Clone, Debug)]
pub struct DeviceProfile {
    pub device_id: String,
    pub install_id: String,
    pub session_id: String,
    pub installed: bool,
}

#[derive(Clone)]
pub struct Session {
    pub phone_number: String,
    pub device: DeviceProfile,
    pub hinge_identity_id: Option<String>,
    pub hinge_auth_token: Option<SecretString>,
    pub sendbird_auth_token: Option<SecretString>,
    pub sendbird_session_key: Option<SecretString>,
}

impl std::fmt::Debug for Session {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Session")
            .field("phone_number", &self.phone_number)
            .field("device", &self.device)
            .field("hinge_identity_id", &self.hinge_identity_id)
            .field(
                "hinge_auth_token",
                &self.hinge_auth_token.as_ref().map(|_| "[redacted]"),
            )
            .field(
                "sendbird_auth_token",
                &self.sendbird_auth_token.as_ref().map(|_| "[redacted]"),
            )
            .field(
                "sendbird_session_key",
                &self.sendbird_session_key.as_ref().map(|_| "[redacted]"),
            )
            .finish()
    }
}

impl Session {
    pub fn from_inner<S: Storage + Clone>(client: &HingeClient<S>) -> Self {
        Self {
            phone_number: client.phone_number.clone(),
            device: DeviceProfile {
                device_id: client.device_id.clone(),
                install_id: client.install_id.clone(),
                session_id: client.session_id.clone(),
                installed: client.installed,
            },
            hinge_identity_id: client
                .hinge_auth
                .as_ref()
                .map(|token| token.identity_id.clone()),
            hinge_auth_token: client
                .hinge_auth
                .as_ref()
                .map(|token| SecretString::new(token.token.clone().into())),
            sendbird_auth_token: client
                .sendbird_auth
                .as_ref()
                .map(|token| SecretString::new(token.token.clone().into())),
            sendbird_session_key: client
                .sendbird_session_key
                .as_ref()
                .map(|key| SecretString::new(key.clone().into())),
        }
    }
}

pub struct ClientBuilder {
    phone_number: Option<String>,
    config: Config,
    secret_store: Option<Arc<dyn SecretStore>>,
}

impl ClientBuilder {
    pub fn new() -> Self {
        Self {
            phone_number: None,
            config: Config::default(),
            secret_store: None,
        }
    }

    pub fn phone_number(mut self, phone_number: impl Into<String>) -> Self {
        self.phone_number = Some(phone_number.into());
        self
    }

    pub fn settings(mut self, settings: Settings) -> Self {
        self.config.settings = settings;
        self
    }

    pub fn recs_fetch_config(mut self, config: RecsFetchConfig) -> Self {
        self.config.recs_fetch_config = config;
        self
    }

    pub fn public_ids_batch_size(mut self, batch_size: usize) -> Self {
        self.config.public_ids_batch_size = batch_size.max(1);
        self
    }

    pub fn secret_store(mut self, store: Arc<dyn SecretStore>) -> Self {
        self.secret_store = Some(store);
        self
    }

    pub fn build(self) -> Result<Client<FsStorage>, HingeError> {
        let phone_number = self
            .phone_number
            .filter(|value| !value.trim().is_empty())
            .ok_or_else(|| HingeError::Auth("phone number is required".into()))?;
        let mut client = Client::with_storage(phone_number, FsStorage, self.config);
        if let Some(store) = self.secret_store {
            client.inner = client.inner.with_secret_store(store);
        }
        Ok(client)
    }
}

impl Default for ClientBuilder {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Clone)]
pub struct Client<S: Storage + Clone = FsStorage> {
    pub(super) inner: HingeClient<S>,
}

impl Client<FsStorage> {
    pub fn builder() -> ClientBuilder {
        ClientBuilder::new()
    }
}

impl<S: Storage + Clone> Client<S> {
    pub fn with_storage(phone_number: impl Into<String>, storage: S, config: Config) -> Self {
        let mut inner = HingeClient::new(phone_number, storage, Some(config.settings));
        inner.set_recs_fetch_config(config.recs_fetch_config);
        inner.set_public_ids_batch_size(config.public_ids_batch_size);
        Self { inner }
    }

    pub fn from_inner(inner: HingeClient<S>) -> Self {
        Self { inner }
    }

    pub fn with_secret_store(mut self, store: Arc<dyn SecretStore>) -> Self {
        self.inner = self.inner.with_secret_store(store);
        self
    }

    pub fn inner(&self) -> &HingeClient<S> {
        &self.inner
    }

    pub fn inner_mut(&mut self) -> &mut HingeClient<S> {
        &mut self.inner
    }

    pub fn into_inner(self) -> HingeClient<S> {
        self.inner
    }

    pub fn session(&self) -> Session {
        Session::from_inner(&self.inner)
    }

    pub fn set_recs_fetch_config(&mut self, config: RecsFetchConfig) {
        self.inner.set_recs_fetch_config(config);
    }

    pub fn set_public_ids_batch_size(&mut self, batch_size: usize) {
        self.inner.set_public_ids_batch_size(batch_size);
    }

    pub fn auth(&mut self) -> AuthApi<'_, S> {
        AuthApi {
            client: &mut self.inner,
        }
    }

    pub fn recommendations(&mut self) -> RecommendationsApi<'_, S> {
        RecommendationsApi {
            client: &mut self.inner,
        }
    }

    pub fn profiles(&mut self) -> ProfilesApi<'_, S> {
        ProfilesApi {
            client: &mut self.inner,
        }
    }

    pub fn likes(&mut self) -> LikesApi<'_, S> {
        LikesApi {
            client: &mut self.inner,
        }
    }

    pub fn ratings(&mut self) -> RatingsApi<'_, S> {
        RatingsApi {
            client: &mut self.inner,
        }
    }

    pub fn prompts(&mut self) -> PromptsApi<'_, S> {
        PromptsApi {
            client: &mut self.inner,
        }
    }

    pub fn connections(&mut self) -> ConnectionsApi<'_, S> {
        ConnectionsApi {
            client: &mut self.inner,
        }
    }

    pub fn settings(&mut self) -> SettingsApi<'_, S> {
        SettingsApi {
            client: &mut self.inner,
        }
    }

    pub fn chat(&mut self) -> ChatApi<'_, S> {
        ChatApi {
            client: &mut self.inner,
        }
    }

    pub fn persistence(&mut self) -> PersistenceApi<'_, S> {
        PersistenceApi {
            client: &mut self.inner,
        }
    }

    pub fn raw(&mut self) -> RawApi<'_, S> {
        RawApi {
            client: &mut self.inner,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{HingeAuthToken, SendbirdAuthToken};
    use chrono::Utc;

    #[test]
    fn session_debug_redacts_secrets() {
        let mut inner = HingeClient::new("+15555550123", FsStorage, None);
        inner.hinge_auth = Some(HingeAuthToken {
            identity_id: "user-1".into(),
            token: "hinge-secret-token".into(),
            expires: Utc::now(),
        });
        inner.sendbird_auth = Some(SendbirdAuthToken {
            token: "sendbird-secret-token".into(),
            expires: Utc::now(),
        });
        inner.sendbird_session_key = Some("session-secret-key".into());

        let session = Session::from_inner(&inner);
        let debug = format!("{session:?}");

        assert!(debug.contains("[redacted]"));
        assert!(!debug.contains("hinge-secret-token"));
        assert!(!debug.contains("sendbird-secret-token"));
        assert!(!debug.contains("session-secret-key"));
    }

    #[test]
    fn builder_requires_phone_number() {
        let error = match Client::builder().build() {
            Ok(_) => panic!("builder unexpectedly succeeded without a phone number"),
            Err(error) => error,
        };
        assert!(error.to_string().contains("phone number is required"));
    }
}