scim_server/scim_server/
builder.rs

1//! Builder pattern for configuring SCIM server instances.
2//!
3//! This module provides a flexible builder pattern for creating SCIM servers
4//! with different endpoint URL configurations and tenant handling strategies.
5//! This is essential for proper $ref field generation in SCIM responses.
6
7use crate::error::ScimError;
8use crate::providers::ResourceProvider;
9use crate::scim_server::ScimServer;
10
11/// Strategy for handling tenant information in URLs.
12///
13/// Different SCIM clients and Identity Providers expect tenant information
14/// to be represented in URLs in different ways. This enum supports the
15/// most common patterns.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum TenantStrategy {
18    /// Single tenant mode - no tenant information in URLs.
19    /// Example: `https://scim.example.com/v2/Users/123`
20    SingleTenant,
21
22    /// Tenant as subdomain.
23    /// Example: `https://tenantA.scim.example.com/v2/Users/123`
24    Subdomain,
25
26    /// Tenant in URL path before SCIM version.
27    /// Example: `https://scim.example.com/tenantA/v2/Users/123`
28    PathBased,
29}
30
31impl Default for TenantStrategy {
32    fn default() -> Self {
33        TenantStrategy::SingleTenant
34    }
35}
36
37/// Configuration for SCIM server endpoint URLs and tenant handling.
38///
39/// This configuration is used to generate proper $ref fields in SCIM
40/// responses by combining the base URL, tenant strategy, and resource
41/// information.
42#[derive(Debug, Clone)]
43pub struct ScimServerConfig {
44    /// Base URL for the SCIM server (without tenant or path information).
45    /// Examples: "https://scim.example.com", "https://api.company.com"
46    pub base_url: String,
47
48    /// Strategy for incorporating tenant information into URLs.
49    pub tenant_strategy: TenantStrategy,
50
51    /// SCIM protocol version to use in URLs. Defaults to "v2".
52    pub scim_version: String,
53}
54
55impl Default for ScimServerConfig {
56    fn default() -> Self {
57        Self {
58            base_url: "https://localhost".to_string(),
59            tenant_strategy: TenantStrategy::SingleTenant,
60            scim_version: "v2".to_string(),
61        }
62    }
63}
64
65impl ScimServerConfig {
66    /// Generate a complete $ref URL for a resource.
67    ///
68    /// Combines the server configuration with tenant and resource information
69    /// to create a properly formatted SCIM $ref URL.
70    ///
71    /// # Arguments
72    ///
73    /// * `tenant_id` - Optional tenant identifier from the request context
74    /// * `resource_type` - SCIM resource type (e.g., "Users", "Groups")
75    /// * `resource_id` - Unique identifier of the resource
76    ///
77    /// # Returns
78    ///
79    /// A complete $ref URL following SCIM 2.0 specification
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if tenant information is required but missing
84    pub fn generate_ref_url(
85        &self,
86        tenant_id: Option<&str>,
87        resource_type: &str,
88        resource_id: &str,
89    ) -> Result<String, ScimError> {
90        match &self.tenant_strategy {
91            TenantStrategy::SingleTenant => Ok(format!(
92                "{}/{}/{}/{}",
93                self.base_url, self.scim_version, resource_type, resource_id
94            )),
95            TenantStrategy::Subdomain => {
96                let tenant = tenant_id.ok_or_else(|| {
97                    ScimError::invalid_request(
98                        "Tenant ID required for subdomain strategy but not provided",
99                    )
100                })?;
101
102                // Extract domain from base URL and prepend tenant
103                let url_without_protocol = self
104                    .base_url
105                    .strip_prefix("https://")
106                    .or_else(|| self.base_url.strip_prefix("http://"))
107                    .or_else(|| self.base_url.strip_prefix("mcp://"))
108                    .ok_or_else(|| ScimError::internal("Invalid base URL format"))?;
109
110                let protocol = if self.base_url.starts_with("https://") {
111                    "https"
112                } else if self.base_url.starts_with("http://") {
113                    "http"
114                } else {
115                    "mcp"
116                };
117
118                Ok(format!(
119                    "{}://{}.{}/{}/{}/{}",
120                    protocol,
121                    tenant,
122                    url_without_protocol,
123                    self.scim_version,
124                    resource_type,
125                    resource_id
126                ))
127            }
128            TenantStrategy::PathBased => {
129                let tenant = tenant_id.ok_or_else(|| {
130                    ScimError::invalid_request(
131                        "Tenant ID required for path-based strategy but not provided",
132                    )
133                })?;
134
135                Ok(format!(
136                    "{}/{}/{}/{}/{}",
137                    self.base_url, tenant, self.scim_version, resource_type, resource_id
138                ))
139            }
140        }
141    }
142
143    /// Validate the configuration.
144    ///
145    /// Ensures the base URL and other configuration parameters are valid.
146    pub fn validate(&self) -> Result<(), ScimError> {
147        if self.base_url.is_empty() {
148            return Err(ScimError::internal("Base URL cannot be empty"));
149        }
150
151        if !self.base_url.starts_with("http://")
152            && !self.base_url.starts_with("https://")
153            && !self.base_url.starts_with("mcp://")
154        {
155            return Err(ScimError::internal(
156                "Base URL must start with http://, https://, or mcp://",
157            ));
158        }
159
160        if self.scim_version.is_empty() {
161            return Err(ScimError::internal("SCIM version cannot be empty"));
162        }
163
164        Ok(())
165    }
166}
167
168/// Builder for configuring and creating SCIM server instances.
169///
170/// Provides a fluent API for setting up endpoint URLs and tenant handling
171/// strategies before creating the final `ScimServer` instance.
172///
173/// # Examples
174///
175/// ```rust
176/// use scim_server::ScimServerBuilder;
177/// use scim_server::TenantStrategy;
178///
179/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
180/// # let provider = scim_server::providers::StandardResourceProvider::new(
181/// #     scim_server::storage::InMemoryStorage::new()
182/// # );
183///
184/// // Single tenant server
185/// let server = ScimServerBuilder::new(provider.clone())
186///     .with_base_url("https://scim.company.com")
187///     .build()?;
188///
189/// // Multi-tenant with subdomains
190/// let server = ScimServerBuilder::new(provider.clone())
191///     .with_base_url("https://scim.company.com")
192///     .with_tenant_strategy(TenantStrategy::Subdomain)
193///     .build()?;
194///
195/// // Multi-tenant with path-based tenants
196/// let server = ScimServerBuilder::new(provider)
197///     .with_base_url("https://api.company.com")
198///     .with_tenant_strategy(TenantStrategy::PathBased)
199///     .with_scim_version("v2.1")
200///     .build()?;
201/// # Ok(())
202/// # }
203/// ```
204pub struct ScimServerBuilder<P> {
205    provider: P,
206    config: ScimServerConfig,
207}
208
209impl<P: ResourceProvider> ScimServerBuilder<P> {
210    /// Create a new SCIM server builder with a resource provider.
211    ///
212    /// Starts with default configuration (single tenant, localhost base URL).
213    pub fn new(provider: P) -> Self {
214        Self {
215            provider,
216            config: ScimServerConfig::default(),
217        }
218    }
219
220    /// Set the base URL for the SCIM server.
221    ///
222    /// This should be the root URL without any tenant or SCIM path information.
223    ///
224    /// # Examples
225    ///
226    /// - `"https://scim.company.com"`
227    /// - `"https://api.company.com"`
228    /// - `"http://localhost:8080"`
229    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
230        self.config.base_url = base_url.into();
231        self
232    }
233
234    /// Set the tenant handling strategy.
235    ///
236    /// Determines how tenant information from request contexts is incorporated
237    /// into generated $ref URLs.
238    pub fn with_tenant_strategy(mut self, strategy: TenantStrategy) -> Self {
239        self.config.tenant_strategy = strategy;
240        self
241    }
242
243    /// Set the SCIM protocol version to use in URLs.
244    ///
245    /// Defaults to "v2" if not specified.
246    pub fn with_scim_version(mut self, version: impl Into<String>) -> Self {
247        self.config.scim_version = version.into();
248        self
249    }
250
251    /// Build the configured SCIM server.
252    ///
253    /// Validates the configuration and creates the final `ScimServer` instance.
254    ///
255    /// # Errors
256    ///
257    /// Returns a `ScimError` if the configuration is invalid or if server
258    /// initialization fails.
259    pub fn build(self) -> Result<ScimServer<P>, ScimError> {
260        self.config.validate()?;
261        ScimServer::with_config(self.provider, self.config)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_single_tenant_ref_url_generation() {
271        let config = ScimServerConfig {
272            base_url: "https://scim.example.com".to_string(),
273            tenant_strategy: TenantStrategy::SingleTenant,
274            scim_version: "v2".to_string(),
275        };
276
277        let url = config.generate_ref_url(None, "Users", "12345").unwrap();
278        assert_eq!(url, "https://scim.example.com/v2/Users/12345");
279    }
280
281    #[test]
282    fn test_subdomain_tenant_ref_url_generation() {
283        let config = ScimServerConfig {
284            base_url: "https://scim.example.com".to_string(),
285            tenant_strategy: TenantStrategy::Subdomain,
286            scim_version: "v2".to_string(),
287        };
288
289        let url = config
290            .generate_ref_url(Some("acme"), "Groups", "67890")
291            .unwrap();
292        assert_eq!(url, "https://acme.scim.example.com/v2/Groups/67890");
293    }
294
295    #[test]
296    fn test_path_based_tenant_ref_url_generation() {
297        let config = ScimServerConfig {
298            base_url: "https://api.company.com".to_string(),
299            tenant_strategy: TenantStrategy::PathBased,
300            scim_version: "v2".to_string(),
301        };
302
303        let url = config
304            .generate_ref_url(Some("tenant1"), "Users", "abc123")
305            .unwrap();
306        assert_eq!(url, "https://api.company.com/tenant1/v2/Users/abc123");
307    }
308
309    #[test]
310    fn test_missing_tenant_error() {
311        let config = ScimServerConfig {
312            base_url: "https://scim.example.com".to_string(),
313            tenant_strategy: TenantStrategy::Subdomain,
314            scim_version: "v2".to_string(),
315        };
316
317        let result = config.generate_ref_url(None, "Users", "12345");
318        assert!(result.is_err());
319        assert!(
320            result
321                .unwrap_err()
322                .to_string()
323                .contains("Tenant ID required")
324        );
325    }
326
327    #[test]
328    fn test_config_validation() {
329        let mut config = ScimServerConfig::default();
330        assert!(config.validate().is_ok());
331
332        config.base_url = "".to_string();
333        assert!(config.validate().is_err());
334
335        config.base_url = "invalid-url".to_string();
336        assert!(config.validate().is_err());
337
338        config.base_url = "https://valid.com".to_string();
339        config.scim_version = "".to_string();
340        assert!(config.validate().is_err());
341    }
342
343    #[test]
344    fn test_builder_pattern() {
345        // This is a compile-time test to ensure the builder API works
346        fn _test_builder_compiles() {
347            use crate::providers::StandardResourceProvider;
348            use crate::storage::InMemoryStorage;
349
350            let storage = InMemoryStorage::new();
351            let provider = StandardResourceProvider::new(storage);
352
353            let _builder = ScimServerBuilder::new(provider)
354                .with_base_url("https://test.com")
355                .with_tenant_strategy(TenantStrategy::PathBased)
356                .with_scim_version("v2.1");
357        }
358    }
359}