use std::fmt;
use chrono::{DateTime, Utc};
use ironflow_core::account::AccountFormField;
use ironflow_store::entities::{
AccountWindowStatus, ProviderAccount, ProviderAccountUsagePoint, ProviderAccountWindow,
};
use serde::{Deserialize, Deserializer, Serialize};
use uuid::Uuid;
use validator::{Validate, ValidationError};
const MAX_SLUG_LEN: usize = 63;
fn is_slug(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
value.len() <= MAX_SLUG_LEN
&& (first.is_ascii_lowercase() || first.is_ascii_digit())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn validate_slug(value: &str) -> Result<(), ValidationError> {
if is_slug(value) {
return Ok(());
}
let mut err = ValidationError::new("invalid_slug");
err.message = Some("must match ^[a-z0-9][a-z0-9-]{0,62}$".into());
Err(err)
}
fn validate_tags(tags: &[String]) -> Result<(), ValidationError> {
if tags.iter().all(|t| is_slug(t)) {
return Ok(());
}
let mut err = ValidationError::new("invalid_tag");
err.message = Some("every tag must match ^[a-z0-9][a-z0-9-]{0,62}$".into());
Err(err)
}
fn validate_threshold(threshold: f64) -> Result<(), ValidationError> {
if threshold > 0.0 && threshold <= 1.0 {
return Ok(());
}
let mut err = ValidationError::new("invalid_threshold");
err.message = Some("alert_threshold must be in (0, 1]".into());
Err(err)
}
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
T::deserialize(deserializer).map(Some)
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Deserialize, Validate)]
pub struct CreateProviderAccountRequest {
#[validate(custom(function = "validate_slug"))]
pub name: String,
#[validate(length(min = 1, max = 200))]
pub display_name: Option<String>,
#[validate(length(min = 1, max = 64))]
pub kind: String,
#[validate(length(min = 1, max = 4096))]
pub token: String,
pub enabled: Option<bool>,
pub priority: Option<i32>,
#[validate(custom(function = "validate_tags"))]
pub tags: Option<Vec<String>>,
#[validate(range(min = 1))]
pub max_concurrency: Option<u32>,
#[validate(custom(function = "validate_threshold"))]
pub alert_threshold: Option<f64>,
pub expires_at: Option<DateTime<Utc>>,
#[validate(length(max = 64))]
pub plan: Option<String>,
}
impl fmt::Debug for CreateProviderAccountRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CreateProviderAccountRequest")
.field("name", &self.name)
.field("kind", &self.kind)
.field("token", &"[REDACTED]")
.finish_non_exhaustive()
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Default, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct UpdateProviderAccountRequest {
#[validate(length(min = 1, max = 200))]
pub display_name: Option<String>,
pub enabled: Option<bool>,
pub priority: Option<i32>,
#[validate(custom(function = "validate_tags"))]
pub tags: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_some")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<u32>))]
pub max_concurrency: Option<Option<u32>>,
#[validate(custom(function = "validate_threshold"))]
pub alert_threshold: Option<f64>,
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, deserialize_with = "deserialize_some")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
pub plan: Option<Option<String>>,
#[validate(length(min = 1, max = 4096))]
pub token: Option<String>,
}
impl UpdateProviderAccountRequest {
pub fn validate_nested(&self) -> Result<(), String> {
if self.max_concurrency == Some(Some(0)) {
return Err("max_concurrency must be >= 1".to_string());
}
if let Some(Some(plan)) = &self.plan
&& plan.len() > 64
{
return Err("plan must be at most 64 characters".to_string());
}
Ok(())
}
pub fn changed_fields(&self) -> Vec<&'static str> {
[
("display_name", self.display_name.is_some()),
("enabled", self.enabled.is_some()),
("priority", self.priority.is_some()),
("tags", self.tags.is_some()),
("max_concurrency", self.max_concurrency.is_some()),
("alert_threshold", self.alert_threshold.is_some()),
("expires_at", self.expires_at.is_some()),
("plan", self.plan.is_some()),
]
.into_iter()
.filter_map(|(name, present)| present.then_some(name))
.collect()
}
}
impl fmt::Debug for UpdateProviderAccountRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UpdateProviderAccountRequest")
.field("fields", &self.changed_fields())
.field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
.finish()
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountState {
Ok,
NearLimit,
Limited,
TokenInvalid,
NeverUsed,
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountWindowResponse {
pub window: String,
pub utilization: f64,
pub resets_at: Option<DateTime<Utc>>,
pub status: AccountWindowStatus,
pub model_scope: Option<String>,
pub observed_at: DateTime<Utc>,
}
impl From<ProviderAccountWindow> for AccountWindowResponse {
fn from(w: ProviderAccountWindow) -> Self {
Self {
window: w.window,
utilization: w.utilization,
resets_at: w.resets_at,
status: w.status,
model_scope: w.model_scope,
observed_at: w.observed_at,
}
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountUsagePointResponse {
pub window: String,
pub utilization: f64,
pub resets_at: Option<DateTime<Utc>>,
pub status: AccountWindowStatus,
pub model_scope: Option<String>,
pub observed_at: DateTime<Utc>,
}
impl From<ProviderAccountUsagePoint> for AccountUsagePointResponse {
fn from(p: ProviderAccountUsagePoint) -> Self {
Self {
window: p.window,
utilization: p.utilization,
resets_at: p.resets_at,
status: p.status,
model_scope: p.model_scope,
observed_at: p.observed_at,
}
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderAccountResponse {
pub id: Uuid,
pub name: String,
pub display_name: String,
pub kind: String,
pub enabled: bool,
pub priority: i32,
pub tags: Vec<String>,
pub max_concurrency: Option<u32>,
pub alert_threshold: f64,
pub expires_at: DateTime<Utc>,
pub plan: Option<String>,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub auth_failed_at: Option<DateTime<Utc>>,
pub state: AccountState,
pub windows: Vec<AccountWindowResponse>,
}
impl ProviderAccountResponse {
pub fn new(
account: ProviderAccount,
windows: Vec<ProviderAccountWindow>,
state: AccountState,
) -> Self {
Self {
id: account.id,
name: account.name,
display_name: account.display_name,
kind: account.kind,
enabled: account.enabled,
priority: account.priority,
tags: account.tags,
max_concurrency: account.max_concurrency,
alert_threshold: account.alert_threshold,
expires_at: account.expires_at,
plan: account.plan,
created_by: account.created_by,
created_at: account.created_at,
updated_at: account.updated_at,
auth_failed_at: account.auth_failed_at,
state,
windows: windows
.into_iter()
.map(AccountWindowResponse::from)
.collect(),
}
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderAccountUsageResponse {
pub account_id: Uuid,
pub name: String,
pub windows: Vec<AccountWindowResponse>,
pub history: Vec<AccountUsagePointResponse>,
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountTestResult {
Valid,
Limited,
Unauthorized,
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderAccountTestResponse {
pub result: AccountTestResult,
pub windows: Vec<AccountWindowResponse>,
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountFormFieldResponse {
pub name: String,
pub label: String,
pub secret: bool,
pub help: String,
}
impl From<AccountFormField> for AccountFormFieldResponse {
fn from(field: AccountFormField) -> Self {
Self {
name: field.name.to_string(),
label: field.label.to_string(),
secret: field.secret,
help: field.help.to_string(),
}
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountKindResponse {
pub id: String,
pub display_name: String,
pub fields: Vec<AccountFormFieldResponse>,
}
#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
#[derive(Debug, Default, Deserialize)]
pub struct ListProviderAccountsQuery {
pub kind: Option<String>,
pub page: Option<u32>,
pub per_page: Option<u32>,
}
#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
#[derive(Debug, Default, Deserialize)]
pub struct UsageQuery {
pub days: Option<u32>,
}
impl UsageQuery {
pub fn days(&self) -> u32 {
self.days.unwrap_or(30).clamp(1, 90)
}
}
#[cfg(test)]
mod tests {
use serde_json::{from_str, json};
use super::*;
const TOKEN: &str = "sk-ant-oat01-secret-token-value";
fn create_request(name: &str) -> CreateProviderAccountRequest {
from_str(&json!({"name": name, "kind": "claude_subscription", "token": TOKEN}).to_string())
.unwrap()
}
#[test]
fn slug_validation() {
assert!(is_slug("perso-max"));
assert!(is_slug("0"));
assert!(!is_slug(""));
assert!(!is_slug("-perso"));
assert!(!is_slug("Perso"));
assert!(!is_slug("perso_max"));
assert!(!is_slug(&"a".repeat(64)));
assert!(is_slug(&"a".repeat(63)));
}
#[test]
fn create_request_validates_name_and_tags() {
assert!(create_request("perso").validate().is_ok());
assert!(create_request("Bad Name").validate().is_err());
let mut req = create_request("perso");
req.tags = Some(vec!["ok".to_string(), "Not OK".to_string()]);
assert!(req.validate().is_err());
let mut req = create_request("perso");
req.alert_threshold = Some(0.0);
assert!(req.validate().is_err());
let mut req = create_request("perso");
req.max_concurrency = Some(0);
assert!(req.validate().is_err());
}
#[test]
fn request_debug_redacts_token() {
let req = create_request("perso");
assert!(!format!("{req:?}").contains(TOKEN));
let update = UpdateProviderAccountRequest {
token: Some(TOKEN.to_string()),
..UpdateProviderAccountRequest::default()
};
assert!(!format!("{update:?}").contains(TOKEN));
}
#[test]
fn update_request_distinguishes_null_from_missing() {
let update: UpdateProviderAccountRequest =
from_str(r#"{"max_concurrency": null, "priority": 5}"#).unwrap();
assert_eq!(update.max_concurrency, Some(None));
assert_eq!(update.plan, None);
assert_eq!(update.changed_fields(), vec!["priority", "max_concurrency"]);
let zero: UpdateProviderAccountRequest = from_str(r#"{"max_concurrency": 0}"#).unwrap();
assert!(zero.validate_nested().is_err());
}
#[test]
fn update_request_rejects_name_change() {
let err = from_str::<UpdateProviderAccountRequest>(r#"{"name": "other"}"#);
assert!(err.is_err());
}
#[test]
fn usage_query_days_are_clamped() {
assert_eq!(UsageQuery::default().days(), 30);
assert_eq!(UsageQuery { days: Some(0) }.days(), 1);
assert_eq!(UsageQuery { days: Some(365) }.days(), 90);
}
}