dimescheduler 0.1.0

Official Rust client library for the Dime.Scheduler API.
Documentation
use crate::api::{
    AppointmentApi, ConnectorApi, CrudApi, GeocodeApi, ImportApi, JobApi, MessageApi,
    NotificationApi, OptimizationApi, ReadOnlyApi, RecommendationApi, RecurringAppointmentApi,
    ResourceCapacityApi, TaskApi, TaskAssignmentApi, TaskBudgetEntryApi, TimeEntryApi,
    TimeSessionApi, UserApi,
};
use crate::error::{Error, Result};
use crate::transport::Transport;
use crate::{entities, types};

/// A Dime.Scheduler API environment. Each has a fixed base URL.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Environment {
    /// `https://api.dimescheduler.com`
    #[default]
    Production,
    /// `https://test.api.dimescheduler.com`
    Test,
    /// `https://sandbox.api.dimescheduler.com`
    Sandbox,
}

impl Environment {
    /// The fixed base URL for this environment.
    pub fn base_url(self) -> &'static str {
        match self {
            Environment::Production => "https://api.dimescheduler.com",
            Environment::Test => "https://test.api.dimescheduler.com",
            Environment::Sandbox => "https://sandbox.api.dimescheduler.com",
        }
    }
}

/// The Dime.Scheduler client. Every entity lives behind a public accessor
/// field (`client.appointments`, `client.resources`, …) that exposes CRUD
/// methods, mirroring the other Dime.Scheduler SDKs.
pub struct DimeSchedulerClient {
    pub action_uris: CrudApi<entities::ActionUri, types::ActionUrlDto>,
    pub appointment_categories: CrudApi<entities::AppointmentCategory, types::CategoryDto>,
    pub appointment_contents: CrudApi<entities::AppointmentContent, ()>,
    pub appointment_containers:
        CrudApi<entities::AppointmentContainer, types::AppointmentContainerDto>,
    pub appointment_field_values:
        CrudApi<entities::AppointmentFieldValue, types::AppointmentFieldValueDto>,
    pub appointment_importances: CrudApi<entities::AppointmentImportance, ()>,
    pub appointment_locked: CrudApi<entities::AppointmentLocked, ()>,
    pub appointment_planning_quantities: CrudApi<entities::AppointmentPlanningQuantity, ()>,
    pub appointment_time_markers: CrudApi<entities::AppointmentTimeMarker, ()>,
    pub appointment_uris: CrudApi<entities::AppointmentUri, types::AppointmentUrlDto>,
    pub assignments: CrudApi<entities::Assignment, types::AssignmentDto>,
    pub captions: CrudApi<entities::Caption, ()>,
    pub categories: CrudApi<entities::Category, types::CategoryDto>,
    pub containers: CrudApi<entities::Container, types::ContainerDto>,
    pub filter_groups: CrudApi<entities::FilterGroup, types::FilterGroupDto>,
    pub filter_values: CrudApi<entities::FilterValue, types::FilterValueDto>,
    pub pins: CrudApi<entities::Pin, types::PinDto>,
    pub project_members: CrudApi<entities::ProjectMember, ()>,
    pub resources: CrudApi<entities::Resource, types::ResourceDto>,
    pub resource_calendars: CrudApi<entities::ResourceCalendar, ()>,
    pub resource_certificates: CrudApi<entities::ResourceCertificate, ()>,
    pub resource_filter_values:
        CrudApi<entities::ResourceFilterValue, types::ResourceFilterValueDto>,
    pub resource_gps_trackings: CrudApi<entities::ResourceGpsTracking, ()>,
    pub resource_uris: CrudApi<entities::ResourceUri, types::ResourceUrlDto>,
    pub tags: CrudApi<entities::Tag, types::TagDto>,
    pub task_containers: CrudApi<entities::TaskContainer, ()>,
    pub task_dependencies: CrudApi<entities::TaskDependency, ()>,
    pub task_filter_values: CrudApi<entities::TaskFilterValue, types::TaskFilterValueDto>,
    pub task_locked: CrudApi<entities::TaskLocked, ()>,
    pub task_uris: CrudApi<entities::TaskUri, types::TaskUrlDto>,
    pub time_markers: CrudApi<entities::TimeMarker, types::TimeMarkerDto>,

    pub appointments: AppointmentApi,
    pub jobs: JobApi,
    pub tasks: TaskApi,
    pub notifications: NotificationApi,
    pub resource_capacities: ResourceCapacityApi,
    pub time_entries: TimeEntryApi,
    pub time_sessions: TimeSessionApi,

    pub calendars: ReadOnlyApi<types::CalendarDto>,
    pub resource_types: ReadOnlyApi<types::ResourceTypeDto>,
    pub appointment_dependencies: ReadOnlyApi<types::AppointmentDependencyDto>,
    pub appointment_fields: ReadOnlyApi<types::AppointmentFieldDto>,

    pub task_budget_entries: TaskBudgetEntryApi,
    pub task_assignments: TaskAssignmentApi,

    pub connectors: ConnectorApi,
    pub geocoding: GeocodeApi,
    pub imports: ImportApi,
    pub messages: MessageApi,
    pub optimization: OptimizationApi,
    pub recommendation: RecommendationApi,
    pub recurring_appointments: RecurringAppointmentApi,
    pub users: UserApi,

    transport: Transport,
}

impl DimeSchedulerClient {
    /// Creates a client for the production environment.
    pub fn new(api_key: impl Into<String>) -> Self {
        Self::with_environment(api_key, Environment::Production)
    }

    /// Creates a client for a specific environment.
    pub fn with_environment(api_key: impl Into<String>, environment: Environment) -> Self {
        Self::from_transport(Transport::new(
            environment.base_url().to_string(),
            api_key.into(),
        ))
    }

    /// Creates a production client from the `DIMESCHEDULER_API_KEY`
    /// environment variable (the same one the `dimescheduler` CLI reads).
    ///
    /// Returns [`Error::MissingApiKey`] if it is unset or empty.
    pub fn from_env() -> Result<Self> {
        Self::from_optional_key(std::env::var("DIMESCHEDULER_API_KEY").ok())
    }

    /// The pure half of [`from_env`](Self::from_env): `None` or an empty key
    /// is [`Error::MissingApiKey`], anything else builds a production client.
    pub(crate) fn from_optional_key(api_key: Option<String>) -> Result<Self> {
        match api_key {
            Some(key) if !key.is_empty() => Ok(Self::new(key)),
            _ => Err(Error::MissingApiKey),
        }
    }

    pub(crate) fn from_transport(transport: Transport) -> Self {
        let t = &transport;
        Self {
            action_uris: CrudApi::new(t.clone(), "/actionUri"),
            appointment_categories: CrudApi::new(t.clone(), "/appointmentCategory"),
            appointment_contents: CrudApi::new(t.clone(), "/appointmentcontent"),
            appointment_containers: CrudApi::new(t.clone(), "/appointmentContainer"),
            appointment_field_values: CrudApi::new(t.clone(), "/appointmentFieldValue"),
            appointment_importances: CrudApi::new(t.clone(), "/appointmentImportance"),
            appointment_locked: CrudApi::new(t.clone(), "/appointmentLocked"),
            appointment_planning_quantities: CrudApi::new(
                t.clone(),
                "/appointmentPlanningQuantity",
            ),
            appointment_time_markers: CrudApi::new(t.clone(), "/appointmentTimeMarker"),
            appointment_uris: CrudApi::new(t.clone(), "/appointmentUri"),
            assignments: CrudApi::new(t.clone(), "/assignment"),
            captions: CrudApi::new(t.clone(), "/caption"),
            categories: CrudApi::new(t.clone(), "/category"),
            containers: CrudApi::new(t.clone(), "/container"),
            filter_groups: CrudApi::new(t.clone(), "/filterGroup"),
            filter_values: CrudApi::new(t.clone(), "/filterValue"),
            pins: CrudApi::new(t.clone(), "/pin"),
            project_members: CrudApi::new(t.clone(), "/projectMember"),
            resources: CrudApi::new(t.clone(), "/resource"),
            resource_calendars: CrudApi::new(t.clone(), "/resourceCalendar"),
            resource_certificates: CrudApi::new(t.clone(), "/resourceCertificate"),
            resource_filter_values: CrudApi::new(t.clone(), "/resourceFilterValue"),
            resource_gps_trackings: CrudApi::new(t.clone(), "/resourceGpsTracking"),
            resource_uris: CrudApi::new(t.clone(), "/resourceUri"),
            tags: CrudApi::new(t.clone(), "/tag"),
            task_containers: CrudApi::new(t.clone(), "/taskContainer"),
            task_dependencies: CrudApi::new(t.clone(), "/taskDependency"),
            task_filter_values: CrudApi::new(t.clone(), "/taskFilterValue"),
            task_locked: CrudApi::new(t.clone(), "/taskLocked"),
            task_uris: CrudApi::new(t.clone(), "/taskUri"),
            time_markers: CrudApi::new(t.clone(), "/timeMarker"),
            appointments: AppointmentApi::new(t.clone()),
            jobs: JobApi::new(t.clone()),
            tasks: TaskApi::new(t.clone()),
            notifications: NotificationApi::new(t.clone()),
            resource_capacities: ResourceCapacityApi::new(t.clone()),
            time_entries: TimeEntryApi::new(t.clone()),
            time_sessions: TimeSessionApi::new(t.clone()),
            calendars: ReadOnlyApi::new(t.clone(), "/calendar"),
            resource_types: ReadOnlyApi::new(t.clone(), "/resourcetype"),
            appointment_dependencies: ReadOnlyApi::new(t.clone(), "/appointmentDependency"),
            appointment_fields: ReadOnlyApi::new(t.clone(), "/appointmentField"),
            task_budget_entries: TaskBudgetEntryApi::new(t.clone()),
            task_assignments: TaskAssignmentApi::new(t.clone()),
            connectors: ConnectorApi::new(t.clone()),
            geocoding: GeocodeApi::new(t.clone()),
            imports: ImportApi::new(t.clone()),
            messages: MessageApi::new(t.clone()),
            optimization: OptimizationApi::new(t.clone()),
            recommendation: RecommendationApi::new(t.clone()),
            recurring_appointments: RecurringAppointmentApi::new(t.clone()),
            users: UserApi::new(t.clone()),
            transport,
        }
    }

    /// The base URL requests are sent to.
    pub fn base_url(&self) -> &str {
        self.transport.base_url()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn environment_urls_are_fixed() {
        assert_eq!(
            Environment::Production.base_url(),
            "https://api.dimescheduler.com"
        );
        assert_eq!(
            Environment::Test.base_url(),
            "https://test.api.dimescheduler.com"
        );
        assert_eq!(
            Environment::Sandbox.base_url(),
            "https://sandbox.api.dimescheduler.com"
        );
        assert_eq!(Environment::default(), Environment::Production);
    }

    #[test]
    fn new_targets_production_and_with_environment_targets_the_choice() {
        assert_eq!(
            DimeSchedulerClient::new("k").base_url(),
            "https://api.dimescheduler.com"
        );
        assert_eq!(
            DimeSchedulerClient::with_environment("k", Environment::Sandbox).base_url(),
            "https://sandbox.api.dimescheduler.com"
        );
    }

    #[test]
    fn from_optional_key_rejects_missing_or_empty_and_accepts_a_value() {
        // from_env is a thin wrapper over this; testing the pure function
        // avoids mutating process env, which races reqwest's proxy getenv
        // calls in sibling tests (setenv/getenv is UB on glibc).
        assert!(matches!(
            DimeSchedulerClient::from_optional_key(None),
            Err(Error::MissingApiKey)
        ));
        assert!(matches!(
            DimeSchedulerClient::from_optional_key(Some(String::new())),
            Err(Error::MissingApiKey)
        ));
        assert_eq!(
            DimeSchedulerClient::from_optional_key(Some("k".into()))
                .unwrap()
                .base_url(),
            "https://api.dimescheduler.com"
        );
    }
}