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}