Skip to main content

lastid_sdk/client/
builder.rs

1//! Client builder with type-state pattern.
2
3use std::marker::PhantomData;
4#[cfg(not(target_arch = "wasm32"))]
5use std::path::Path;
6
7use super::LastIDClient;
8use crate::config::SDKConfig;
9use crate::crypto::DPoPKeyPair;
10use crate::error::LastIDError;
11
12/// Type-state marker: Configuration not yet provided
13pub struct NoConfig;
14
15/// Type-state marker: Configuration provided
16pub struct HasConfig;
17
18/// Builder for `LastIDClient` with type-state pattern.
19///
20/// # Type-State Pattern
21///
22/// The builder enforces that configuration must be provided before building:
23/// - `ClientBuilder<NoConfig>` - Initial state, no config
24/// - `ClientBuilder<HasConfig>` - Config provided, can build
25///
26/// # Example
27///
28/// ```rust,no_run
29/// use lastid_sdk::ClientBuilder;
30///
31/// # async fn example() -> Result<(), lastid_sdk::LastIDError> {
32/// // Auto-discover configuration
33/// let client = ClientBuilder::new().with_auto_config()?.build()?;
34///
35/// // Or use explicit configuration
36/// use lastid_sdk::SDKConfig;
37///
38/// let config = SDKConfig::builder()
39///     .idp_endpoint("https://human.lastid.co")
40///     .build()?;
41///
42/// let client = ClientBuilder::new().with_config(config).build()?;
43/// # Ok(())
44/// # }
45/// ```
46///
47/// # Serverless Keypair Persistence
48///
49/// For serverless deployments, see
50/// [`with_keypair`](ClientBuilder::with_keypair) to restore a persisted `DPoP`
51/// keypair across invocations.
52pub struct ClientBuilder<State> {
53    config: Option<SDKConfig>,
54    keypair: Option<DPoPKeyPair>,
55    _marker: PhantomData<State>,
56}
57
58impl Default for ClientBuilder<NoConfig> {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl ClientBuilder<NoConfig> {
65    /// Create a new client builder.
66    #[must_use]
67    pub const fn new() -> Self {
68        Self {
69            config: None,
70            keypair: None,
71            _marker: PhantomData,
72        }
73    }
74
75    /// Auto-discover configuration from filesystem and environment.
76    ///
77    /// Searches for TOML files in standard locations:
78    /// 1. `./lastid.toml` (current directory)
79    /// 2. `~/.config/lastid/config.toml` (user config)
80    /// 3. `/etc/lastid/config.toml` (system config, Unix only)
81    ///
82    /// Environment variables override file-based configuration.
83    ///
84    /// # Errors
85    ///
86    /// Returns `LastIDError::Config` if no configuration is found.
87    #[must_use = "this method consumes the builder and returns a configured builder that should be used"]
88    pub fn with_auto_config(self) -> Result<ClientBuilder<HasConfig>, LastIDError> {
89        let config = SDKConfig::discover()?;
90        Ok(ClientBuilder {
91            config: Some(config),
92            keypair: self.keypair,
93            _marker: PhantomData,
94        })
95    }
96
97    /// Use an explicit configuration.
98    #[must_use]
99    pub fn with_config(self, config: SDKConfig) -> ClientBuilder<HasConfig> {
100        ClientBuilder {
101            config: Some(config),
102            keypair: self.keypair,
103            _marker: PhantomData,
104        }
105    }
106
107    /// Load configuration from a TOML file.
108    ///
109    /// # Note
110    ///
111    /// This method is only available on native platforms (not WASM).
112    /// In WASM, use [`with_config`](Self::with_config) or store config in
113    /// localStorage.
114    ///
115    /// # Errors
116    ///
117    /// Returns `LastIDError::Config` if the file cannot be read or parsed.
118    #[cfg(not(target_arch = "wasm32"))]
119    #[must_use = "this method consumes the builder and returns a configured builder that should be used"]
120    pub fn with_toml_file(self, path: &Path) -> Result<ClientBuilder<HasConfig>, LastIDError> {
121        let mut config = SDKConfig::from_file(path)?;
122        config.apply_env_overrides();
123        Ok(ClientBuilder {
124            config: Some(config),
125            keypair: self.keypair,
126            _marker: PhantomData,
127        })
128    }
129}
130
131impl ClientBuilder<HasConfig> {
132    /// Use a pre-generated or restored `DPoP` keypair.
133    ///
134    /// This is useful for serverless deployments where the keypair needs to be
135    /// persisted across invocations. The keypair can be serialized to JSON and
136    /// stored in a secrets manager, then deserialized and passed to this
137    /// method.
138    ///
139    /// # Security
140    ///
141    /// The keypair contains the private key. Ensure it is stored encrypted at
142    /// rest (e.g., AWS Secrets Manager, `HashiCorp` Vault, encrypted
143    /// environment variables).
144    ///
145    /// # Example
146    ///
147    /// Using a generated keypair:
148    ///
149    /// ```rust,no_run
150    /// use lastid_sdk::crypto::DPoPKeyPair;
151    /// use lastid_sdk::{ClientBuilder, SDKConfig};
152    ///
153    /// # fn example() -> Result<(), lastid_sdk::LastIDError> {
154    /// let keypair = DPoPKeyPair::generate()?;
155    ///
156    /// let config = SDKConfig::builder()
157    ///     .idp_endpoint("https://human.lastid.co")
158    ///     .client_id("my-app")
159    ///     .build()?;
160    ///
161    /// let client = ClientBuilder::new()
162    ///     .with_config(config)
163    ///     .with_keypair(keypair)
164    ///     .build()?;
165    /// # Ok(())
166    /// # }
167    /// ```
168    ///
169    /// Serializing and restoring a keypair (requires `keypair-serialization`
170    /// feature):
171    #[cfg_attr(feature = "keypair-serialization", doc = "```rust,no_run")]
172    #[cfg_attr(not(feature = "keypair-serialization"), doc = "```rust,ignore")]
173    /// use lastid_sdk::{ClientBuilder, SDKConfig};
174    /// use lastid_sdk::crypto::DPoPKeyPair;
175    ///
176    /// # fn example() -> Result<(), lastid_sdk::LastIDError> {
177    /// // Generate and serialize a keypair for initial deployment
178    /// let keypair = DPoPKeyPair::generate()?;
179    /// let serialized = serde_json::to_string(&keypair).unwrap();
180    /// // Store `serialized` in your secrets manager...
181    ///
182    /// // Later, in a serverless function cold start:
183    /// let restored: DPoPKeyPair = serde_json::from_str(&serialized).unwrap();
184    ///
185    /// let config = SDKConfig::builder()
186    ///     .idp_endpoint("https://human.lastid.co")
187    ///     .client_id("my-app")
188    ///     .build()?;
189    ///
190    /// let client = ClientBuilder::new()
191    ///     .with_config(config)
192    ///     .with_keypair(restored)
193    ///     .build()?;
194    /// # Ok(())
195    /// # }
196    /// ```
197    #[must_use]
198    pub fn with_keypair(mut self, keypair: DPoPKeyPair) -> Self {
199        self.keypair = Some(keypair);
200        self
201    }
202
203    /// Build the client.
204    ///
205    /// # Errors
206    ///
207    /// Returns `LastIDError` if:
208    /// - Configuration validation fails
209    /// - HTTP client creation fails
210    /// - `DPoP` key initialization fails
211    #[must_use = "this method consumes the builder and returns a client that should be used"]
212    pub fn build(self) -> Result<LastIDClient, LastIDError> {
213        let config = self.config.expect("Config must be set in HasConfig state");
214        config.validate()?;
215        LastIDClient::with_optional_keypair(config, self.keypair)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_builder_with_config() {
225        let config = SDKConfig::builder()
226            .idp_endpoint("https://test.lastid.co")
227            .client_id("test-client")
228            .build()
229            .unwrap();
230
231        // Build the client and verify configuration is correctly applied
232        let client = ClientBuilder::new().with_config(config).build().unwrap();
233        assert_eq!(client.config().idp_endpoint, "https://test.lastid.co");
234        assert_eq!(client.config().client_id, "test-client");
235    }
236
237    #[test]
238    fn test_builder_auto_config_handles_missing_config() {
239        // When no config file exists and no env vars, should return an error.
240        // Note: We don't clear env vars since remove_var is unsafe in Rust 2024
241        // edition. Instead, we just verify the result handling is correct.
242        let result = ClientBuilder::new().with_auto_config();
243
244        // In CI/test environments without config files, this should fail.
245        // We verify it either succeeds (config exists) or fails with Config error.
246        match result {
247            Ok(builder) => {
248                // Config was found in environment - verify we can attempt to build
249                // Build may fail due to validation, but the state transition worked
250                let _build_result = builder.build();
251            }
252            Err(e) => {
253                // No config found - this is expected in clean test environments
254                assert!(matches!(e, LastIDError::Config(_)));
255            }
256        }
257    }
258
259    #[test]
260    fn test_builder_with_keypair() {
261        let config = SDKConfig::builder()
262            .idp_endpoint("https://test.lastid.co")
263            .client_id("test-client")
264            .build()
265            .unwrap();
266
267        // Generate a keypair
268        let keypair = DPoPKeyPair::generate().unwrap();
269        let original_thumbprint = keypair.thumbprint().to_string();
270
271        // Build client with the keypair
272        let client = ClientBuilder::new()
273            .with_config(config)
274            .with_keypair(keypair)
275            .build()
276            .unwrap();
277
278        // Verify the client was created with the correct configuration
279        assert_eq!(client.config().idp_endpoint, "https://test.lastid.co");
280        assert_eq!(client.config().client_id, "test-client");
281        // Verify the keypair thumbprint is preserved (client uses our provided keypair)
282        assert_eq!(client.dpop_thumbprint(), original_thumbprint);
283    }
284
285    #[cfg(feature = "keypair-serialization")]
286    #[test]
287    fn test_builder_with_serialized_keypair_roundtrip() {
288        // This test simulates the serverless use case:
289        // 1. Generate keypair on first invocation
290        // 2. Serialize and "store" it
291        // 3. On subsequent invocation, deserialize and use it
292
293        // First invocation: generate and serialize
294        let keypair = DPoPKeyPair::generate().unwrap();
295        let thumbprint = keypair.thumbprint().to_string();
296        let serialized = serde_json::to_string(&keypair).unwrap();
297
298        // Subsequent invocation: deserialize and use
299        let restored: DPoPKeyPair = serde_json::from_str(&serialized).unwrap();
300        assert_eq!(restored.thumbprint(), thumbprint);
301
302        let config = SDKConfig::builder()
303            .idp_endpoint("https://test.lastid.co")
304            .client_id("test-client")
305            .build()
306            .unwrap();
307
308        // Should successfully build with restored keypair
309        let result = ClientBuilder::new()
310            .with_config(config)
311            .with_keypair(restored)
312            .build();
313
314        assert!(result.is_ok());
315    }
316}