1use std::collections::BTreeMap;
22
23use serde::Serialize;
24
25#[derive(Debug, Clone, Serialize)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28pub struct CredentialFormSchema {
29 pub fields: Vec<FormField>,
31 pub instructions_markdown: String,
33}
34
35impl CredentialFormSchema {
36 pub fn empty() -> Self {
38 Self {
39 fields: Vec::new(),
40 instructions_markdown: String::new(),
41 }
42 }
43
44 pub fn api_key(instructions_markdown: impl Into<String>) -> Self {
46 Self {
47 fields: vec![FormField::password("api_key", "API Key").required()],
48 instructions_markdown: instructions_markdown.into(),
49 }
50 }
51
52 pub fn has_groups(&self) -> bool {
56 self.fields.iter().any(|f| f.group.is_some())
57 }
58
59 pub fn validate(&self, fields: &BTreeMap<String, String>) -> Vec<String> {
73 let filled = |name: &str| fields.get(name).is_some_and(|v| !v.trim().is_empty());
74 let mut errors = Vec::new();
75
76 for field in self.fields.iter().filter(|f| f.group.is_none()) {
78 if field.required && !filled(&field.name) {
79 errors.push(format!("{} is required.", field.label));
80 }
81 }
82
83 if !self.has_groups() {
84 return errors;
85 }
86
87 let mut group_labels: Vec<&str> = Vec::new();
89 for field in &self.fields {
90 if let Some(group) = field.group.as_deref()
91 && !group_labels.contains(&group)
92 {
93 group_labels.push(group);
94 }
95 }
96
97 let mut any_group_complete = false;
98 for label in group_labels {
99 let group_fields: Vec<&FormField> = self
100 .fields
101 .iter()
102 .filter(|f| f.group.as_deref() == Some(label))
103 .collect();
104 let touched = group_fields.iter().any(|f| filled(&f.name));
105 let complete = group_fields.iter().all(|f| !f.required || filled(&f.name));
106 if touched && !complete {
107 for field in group_fields
108 .iter()
109 .filter(|f| f.required && !filled(&f.name))
110 {
111 errors.push(format!("{} ({}) is required.", field.label, label));
112 }
113 }
114 if complete && touched {
115 any_group_complete = true;
116 }
117 }
118
119 if !any_group_complete && errors.is_empty() {
120 errors.push("Provide credentials for one of the available methods.".to_string());
121 }
122
123 errors
124 }
125}
126
127#[derive(Debug, Clone, Default, Serialize)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130pub struct FormField {
131 pub name: String,
133 pub label: String,
135 pub field_type: FieldType,
137 pub required: bool,
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub placeholder: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub help_text: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
149 pub default_value: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none")]
155 pub group: Option<String>,
156}
157
158impl FormField {
159 fn new(name: impl Into<String>, label: impl Into<String>, field_type: FieldType) -> Self {
161 Self {
162 name: name.into(),
163 label: label.into(),
164 field_type,
165 required: false,
166 placeholder: None,
167 help_text: None,
168 default_value: None,
169 group: None,
170 }
171 }
172
173 pub fn password(name: impl Into<String>, label: impl Into<String>) -> Self {
175 Self::new(name, label, FieldType::Password)
176 }
177
178 pub fn text(name: impl Into<String>, label: impl Into<String>) -> Self {
180 Self::new(name, label, FieldType::Text)
181 }
182
183 pub fn required(mut self) -> Self {
185 self.required = true;
186 self
187 }
188
189 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
191 self.placeholder = Some(placeholder.into());
192 self
193 }
194
195 pub fn with_help(mut self, help_text: impl Into<String>) -> Self {
197 self.help_text = Some(help_text.into());
198 self
199 }
200
201 pub fn with_default(mut self, default_value: impl Into<String>) -> Self {
203 self.default_value = Some(default_value.into());
204 self
205 }
206
207 pub fn in_group(mut self, group: impl Into<String>) -> Self {
209 self.group = Some(group.into());
210 self
211 }
212}
213
214#[derive(Debug, Clone, Copy, Default, Serialize)]
216#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
217#[serde(rename_all = "snake_case")]
218pub enum FieldType {
219 #[default]
221 Password,
222 Text,
224 Url,
226}
227
228pub fn assemble_credential_document(fields: &BTreeMap<String, String>) -> Option<String> {
239 let non_empty: BTreeMap<&str, &str> = fields
240 .iter()
241 .filter(|(_, v)| !v.trim().is_empty())
242 .map(|(k, v)| (k.as_str(), v.as_str()))
243 .collect();
244
245 match non_empty.len() {
246 0 => None,
247 1 if non_empty.contains_key("api_key") => Some(non_empty["api_key"].to_string()),
248 _ => Some(serde_json::to_string(&non_empty).expect("string map serializes")),
249 }
250}
251
252pub fn parse_credential_document(document: Option<&str>) -> BTreeMap<String, String> {
259 let Some(document) = document else {
260 return BTreeMap::new();
261 };
262
263 if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(document)
264 {
265 let mut fields = BTreeMap::new();
266 for (key, value) in map {
267 if let serde_json::Value::String(s) = value {
268 fields.insert(key, s);
269 }
270 }
271 if !fields.is_empty() {
274 return fields;
275 }
276 }
277
278 BTreeMap::from([("api_key".to_string(), document.to_string())])
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
286 pairs
287 .iter()
288 .map(|(k, v)| (k.to_string(), v.to_string()))
289 .collect()
290 }
291
292 #[test]
293 fn assemble_lone_api_key_stays_raw() {
294 let doc = assemble_credential_document(&map(&[("api_key", "sk-123")]));
295 assert_eq!(doc.as_deref(), Some("sk-123"));
296 }
297
298 #[test]
299 fn assemble_multi_field_is_json_and_drops_empty() {
300 let doc = assemble_credential_document(&map(&[
301 ("access_key_id", "AKID"),
302 ("secret_access_key", "SECRET"),
303 ("region", ""),
304 ]))
305 .expect("doc");
306 let parsed: serde_json::Value = serde_json::from_str(&doc).unwrap();
307 assert_eq!(parsed["access_key_id"], "AKID");
308 assert_eq!(parsed["secret_access_key"], "SECRET");
309 assert!(parsed.get("region").is_none(), "empty fields dropped");
310 }
311
312 #[test]
313 fn assemble_nothing_is_none() {
314 assert!(assemble_credential_document(&map(&[("api_key", " ")])).is_none());
315 assert!(assemble_credential_document(&BTreeMap::new()).is_none());
316 }
317
318 #[test]
319 fn parse_round_trips_multi_field() {
320 let doc = assemble_credential_document(&map(&[
321 ("access_key_id", "AKID"),
322 ("secret_access_key", "SECRET"),
323 ]))
324 .unwrap();
325 let fields = parse_credential_document(Some(&doc));
326 assert_eq!(fields["access_key_id"], "AKID");
327 assert_eq!(fields["secret_access_key"], "SECRET");
328 }
329
330 #[test]
331 fn parse_raw_key_becomes_api_key_field() {
332 let fields = parse_credential_document(Some("sk-raw-key"));
333 assert_eq!(fields["api_key"], "sk-raw-key");
334 }
335
336 #[test]
337 fn parse_legacy_json_oauth_document() {
338 let legacy = r#"{"tenant_id":"t","client_id":"c","client_secret":"s"}"#;
341 let fields = parse_credential_document(Some(legacy));
342 assert_eq!(fields["tenant_id"], "t");
343 assert_eq!(fields["client_secret"], "s");
344 }
345
346 #[test]
347 fn parse_none_is_empty() {
348 assert!(parse_credential_document(None).is_empty());
349 }
350
351 #[test]
352 fn validate_required_field_missing() {
353 let schema = CredentialFormSchema::api_key("");
354 let errors = schema.validate(&BTreeMap::new());
355 assert_eq!(errors.len(), 1);
356 assert!(errors[0].contains("API Key"));
357 }
358
359 #[test]
360 fn validate_grouped_either_or() {
361 let schema = CredentialFormSchema {
363 fields: vec![
364 FormField::password("api_key", "API Key")
365 .required()
366 .in_group("API key"),
367 FormField::text("tenant_id", "Tenant ID")
368 .required()
369 .in_group("OAuth"),
370 FormField::text("client_id", "Client ID")
371 .required()
372 .in_group("OAuth"),
373 FormField::password("client_secret", "Client Secret")
374 .required()
375 .in_group("OAuth"),
376 ],
377 instructions_markdown: String::new(),
378 };
379
380 assert!(!schema.validate(&BTreeMap::new()).is_empty());
382 assert!(schema.validate(&map(&[("api_key", "k")])).is_empty());
384 assert!(
386 schema
387 .validate(&map(&[
388 ("tenant_id", "t"),
389 ("client_id", "c"),
390 ("client_secret", "s"),
391 ]))
392 .is_empty()
393 );
394 let errors = schema.validate(&map(&[("tenant_id", "t")]));
396 assert!(errors.iter().any(|e| e.contains("Client ID")));
397 assert!(errors.iter().any(|e| e.contains("Client Secret")));
398 }
399}