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}