use std::collections::BTreeMap;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct CredentialFormSchema {
pub fields: Vec<FormField>,
pub instructions_markdown: String,
}
impl CredentialFormSchema {
pub fn empty() -> Self {
Self {
fields: Vec::new(),
instructions_markdown: String::new(),
}
}
pub fn api_key(instructions_markdown: impl Into<String>) -> Self {
Self {
fields: vec![FormField::password("api_key", "API Key").required()],
instructions_markdown: instructions_markdown.into(),
}
}
pub fn has_groups(&self) -> bool {
self.fields.iter().any(|f| f.group.is_some())
}
pub fn validate(&self, fields: &BTreeMap<String, String>) -> Vec<String> {
let filled = |name: &str| fields.get(name).is_some_and(|v| !v.trim().is_empty());
let mut errors = Vec::new();
for field in self.fields.iter().filter(|f| f.group.is_none()) {
if field.required && !filled(&field.name) {
errors.push(format!("{} is required.", field.label));
}
}
if !self.has_groups() {
return errors;
}
let mut group_labels: Vec<&str> = Vec::new();
for field in &self.fields {
if let Some(group) = field.group.as_deref()
&& !group_labels.contains(&group)
{
group_labels.push(group);
}
}
let mut any_group_complete = false;
for label in group_labels {
let group_fields: Vec<&FormField> = self
.fields
.iter()
.filter(|f| f.group.as_deref() == Some(label))
.collect();
let touched = group_fields.iter().any(|f| filled(&f.name));
let complete = group_fields.iter().all(|f| !f.required || filled(&f.name));
if touched && !complete {
for field in group_fields
.iter()
.filter(|f| f.required && !filled(&f.name))
{
errors.push(format!("{} ({}) is required.", field.label, label));
}
}
if complete && touched {
any_group_complete = true;
}
}
if !any_group_complete && errors.is_empty() {
errors.push("Provide credentials for one of the available methods.".to_string());
}
errors
}
}
#[derive(Debug, Clone, Default, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FormField {
pub name: String,
pub label: String,
pub field_type: FieldType,
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
}
impl FormField {
fn new(name: impl Into<String>, label: impl Into<String>, field_type: FieldType) -> Self {
Self {
name: name.into(),
label: label.into(),
field_type,
required: false,
placeholder: None,
help_text: None,
default_value: None,
group: None,
}
}
pub fn password(name: impl Into<String>, label: impl Into<String>) -> Self {
Self::new(name, label, FieldType::Password)
}
pub fn text(name: impl Into<String>, label: impl Into<String>) -> Self {
Self::new(name, label, FieldType::Text)
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
pub fn with_help(mut self, help_text: impl Into<String>) -> Self {
self.help_text = Some(help_text.into());
self
}
pub fn with_default(mut self, default_value: impl Into<String>) -> Self {
self.default_value = Some(default_value.into());
self
}
pub fn in_group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
}
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
#[default]
Password,
Text,
Url,
}
pub fn assemble_credential_document(fields: &BTreeMap<String, String>) -> Option<String> {
let non_empty: BTreeMap<&str, &str> = fields
.iter()
.filter(|(_, v)| !v.trim().is_empty())
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
match non_empty.len() {
0 => None,
1 if non_empty.contains_key("api_key") => Some(non_empty["api_key"].to_string()),
_ => Some(serde_json::to_string(&non_empty).expect("string map serializes")),
}
}
pub fn parse_credential_document(document: Option<&str>) -> BTreeMap<String, String> {
let Some(document) = document else {
return BTreeMap::new();
};
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(document)
{
let mut fields = BTreeMap::new();
for (key, value) in map {
if let serde_json::Value::String(s) = value {
fields.insert(key, s);
}
}
if !fields.is_empty() {
return fields;
}
}
BTreeMap::from([("api_key".to_string(), document.to_string())])
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn assemble_lone_api_key_stays_raw() {
let doc = assemble_credential_document(&map(&[("api_key", "sk-123")]));
assert_eq!(doc.as_deref(), Some("sk-123"));
}
#[test]
fn assemble_multi_field_is_json_and_drops_empty() {
let doc = assemble_credential_document(&map(&[
("access_key_id", "AKID"),
("secret_access_key", "SECRET"),
("region", ""),
]))
.expect("doc");
let parsed: serde_json::Value = serde_json::from_str(&doc).unwrap();
assert_eq!(parsed["access_key_id"], "AKID");
assert_eq!(parsed["secret_access_key"], "SECRET");
assert!(parsed.get("region").is_none(), "empty fields dropped");
}
#[test]
fn assemble_nothing_is_none() {
assert!(assemble_credential_document(&map(&[("api_key", " ")])).is_none());
assert!(assemble_credential_document(&BTreeMap::new()).is_none());
}
#[test]
fn parse_round_trips_multi_field() {
let doc = assemble_credential_document(&map(&[
("access_key_id", "AKID"),
("secret_access_key", "SECRET"),
]))
.unwrap();
let fields = parse_credential_document(Some(&doc));
assert_eq!(fields["access_key_id"], "AKID");
assert_eq!(fields["secret_access_key"], "SECRET");
}
#[test]
fn parse_raw_key_becomes_api_key_field() {
let fields = parse_credential_document(Some("sk-raw-key"));
assert_eq!(fields["api_key"], "sk-raw-key");
}
#[test]
fn parse_legacy_json_oauth_document() {
let legacy = r#"{"tenant_id":"t","client_id":"c","client_secret":"s"}"#;
let fields = parse_credential_document(Some(legacy));
assert_eq!(fields["tenant_id"], "t");
assert_eq!(fields["client_secret"], "s");
}
#[test]
fn parse_none_is_empty() {
assert!(parse_credential_document(None).is_empty());
}
#[test]
fn validate_required_field_missing() {
let schema = CredentialFormSchema::api_key("");
let errors = schema.validate(&BTreeMap::new());
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("API Key"));
}
#[test]
fn validate_grouped_either_or() {
let schema = CredentialFormSchema {
fields: vec![
FormField::password("api_key", "API Key")
.required()
.in_group("API key"),
FormField::text("tenant_id", "Tenant ID")
.required()
.in_group("OAuth"),
FormField::text("client_id", "Client ID")
.required()
.in_group("OAuth"),
FormField::password("client_secret", "Client Secret")
.required()
.in_group("OAuth"),
],
instructions_markdown: String::new(),
};
assert!(!schema.validate(&BTreeMap::new()).is_empty());
assert!(schema.validate(&map(&[("api_key", "k")])).is_empty());
assert!(
schema
.validate(&map(&[
("tenant_id", "t"),
("client_id", "c"),
("client_secret", "s"),
]))
.is_empty()
);
let errors = schema.validate(&map(&[("tenant_id", "t")]));
assert!(errors.iter().any(|e| e.contains("Client ID")));
assert!(errors.iter().any(|e| e.contains("Client Secret")));
}
}