Skip to main content

wavs_types/id/
workflow.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use std::{ops::Deref, str::FromStr};
3use thiserror::Error;
4#[cfg(feature = "ts-bindings")]
5use ts_rs::TS;
6use utoipa::ToSchema;
7
8/// It is a string, but with some strict validation rules. It must be lowercase alphanumeric: `[a-z0-9-_]{3,36}`
9#[cfg_attr(feature = "ts-bindings", derive(TS))]
10#[derive(
11    Serialize,
12    Clone,
13    Debug,
14    PartialEq,
15    Eq,
16    PartialOrd,
17    Ord,
18    Hash,
19    ToSchema,
20    bincode::Decode,
21    bincode::Encode,
22)]
23#[serde(transparent)]
24pub struct WorkflowId(String);
25
26impl WorkflowId {
27    /// Validates without taking ownership - good for checking
28    pub fn validate(id: impl AsRef<str>) -> Result<(), WorkflowIdError> {
29        let id = id.as_ref();
30
31        if id.len() < 3 || id.len() > 36 {
32            Err(WorkflowIdError::LengthError)
33        } else if !id
34            .chars()
35            .all(|c| c.is_ascii_lowercase() || c.is_numeric() || c == '_' || c == '-')
36        {
37            Err(WorkflowIdError::CharError)
38        } else {
39            Ok(())
40        }
41    }
42    // take Into<String> instead of ToString so we benefit from zero-cost conversions for common cases
43    // String -> String is a no-op
44    // &str -> String is via std lib magic (internal transmute, ultimately)
45    pub fn new(id: impl Into<String>) -> Result<Self, WorkflowIdError> {
46        let id = id.into();
47
48        Self::validate(&id)?;
49
50        Ok(Self(id))
51    }
52}
53
54impl<'de> Deserialize<'de> for WorkflowId {
55    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
56    where
57        D: Deserializer<'de>,
58    {
59        let s = String::deserialize(deserializer)?;
60        Self::new(s).map_err(serde::de::Error::custom)
61    }
62}
63
64impl AsRef<str> for WorkflowId {
65    fn as_ref(&self) -> &str {
66        &self.0
67    }
68}
69
70impl Deref for WorkflowId {
71    type Target = str;
72
73    fn deref(&self) -> &Self::Target {
74        &self.0
75    }
76}
77
78impl std::fmt::Display for WorkflowId {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(f, "{}", self.0)
81    }
82}
83
84impl TryFrom<&str> for WorkflowId {
85    type Error = WorkflowIdError;
86
87    fn try_from(s: &str) -> Result<Self, Self::Error> {
88        Self::new(s)
89    }
90}
91
92impl FromStr for WorkflowId {
93    type Err = WorkflowIdError;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        Self::new(s.to_string())
97    }
98}
99
100impl Default for WorkflowId {
101    fn default() -> Self {
102        WorkflowId::new("default").unwrap()
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn valid_ids() {
112        WorkflowId::new("foobar").unwrap();
113        WorkflowId::new("foot123").unwrap();
114        WorkflowId::new("123foot").unwrap();
115        WorkflowId::new("two_words").unwrap();
116        WorkflowId::new("kebab-case").unwrap();
117        WorkflowId::new("pretty-1234321-long").unwrap();
118        // 32 chars
119        WorkflowId::new("12345678901234567890123456789012").unwrap();
120    }
121
122    #[test]
123    fn invalid_ids() {
124        // test length
125        let err = WorkflowId::new("fo").unwrap_err();
126        assert_eq!(err, WorkflowIdError::LengthError);
127        let err = WorkflowId::new("1234567890123456789012345678901234567").unwrap_err();
128        assert_eq!(err, WorkflowIdError::LengthError);
129
130        // test chars
131        let err = WorkflowId::new("with space").unwrap_err();
132        assert_eq!(err, WorkflowIdError::CharError);
133        WorkflowId::new("UPPER_SPACE").unwrap_err();
134        WorkflowId::new("Capitalized").unwrap_err();
135        WorkflowId::new("../../etc/passwd").unwrap_err();
136        WorkflowId::new("c:\\\\badfile").unwrap_err();
137    }
138
139    #[test]
140    fn invalid_id_deserialize() {
141        // baseline, make sure we can deserialize properly
142        let id_str = "foo";
143        let id_obj: WorkflowId = serde_json::from_str(&format!("\"{id_str}\"")).unwrap();
144        assert_eq!(id_obj.to_string(), id_str);
145
146        // now do a bad id
147        let id_str = "THIS/IS/BAD";
148        serde_json::from_str::<WorkflowId>(&format!("\"{id_str}\"")).unwrap_err();
149    }
150
151    #[test]
152    fn proper_representation() {
153        let name = "fly2you";
154        let id = WorkflowId::new(name).unwrap();
155        // same string rep
156        assert_eq!(id.to_string(), name.to_string());
157        // can be used AsRef
158        assert_eq!(name, id.as_ref());
159        // deref working (call method from &str on ID)
160        assert_eq!(name.as_bytes(), id.as_bytes())
161    }
162}
163
164#[derive(Error, Debug, PartialEq, Eq, Clone)]
165pub enum WorkflowIdError {
166    #[error("ID must be between 3 and 36 characters")]
167    LengthError,
168    #[error("ID must be lowercase alphanumeric")]
169    CharError,
170}