roas/common/
helpers.rs

1use enumset::EnumSet;
2use regex::Regex;
3use std::collections::HashSet;
4use std::fmt;
5
6use crate::validation::{Error, Options};
7
8/// ValidateWithContext is a trait for validating an object with a context.
9/// It allows the object to be validated with additional context information,
10/// such as the specification and validation options.
11pub trait ValidateWithContext<T> {
12    fn validate_with_context(&self, ctx: &mut Context<T>, path: String);
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct Context<'a, T> {
17    pub spec: &'a T,
18    pub visited: HashSet<String>,
19    pub errors: Vec<String>,
20    pub options: EnumSet<Options>,
21}
22
23pub trait PushError<T> {
24    fn error(&mut self, path: String, args: T);
25}
26
27impl<T> PushError<&str> for Context<'_, T> {
28    fn error(&mut self, path: String, msg: &str) {
29        if msg.starts_with('.') {
30            self.errors.push(format!("{path}{msg}"));
31        } else {
32            self.errors.push(format!("{path}: {msg}"));
33        }
34    }
35}
36
37impl<T> PushError<String> for Context<'_, T> {
38    fn error(&mut self, path: String, msg: String) {
39        self.error(path, msg.as_str());
40    }
41}
42
43impl<T> PushError<fmt::Arguments<'_>> for Context<'_, T> {
44    fn error(&mut self, path: String, args: fmt::Arguments<'_>) {
45        self.error(path, args.to_string().as_str());
46    }
47}
48
49impl<T> Context<'_, T> {
50    pub fn reset(&mut self) {
51        self.visited.clear();
52        self.errors.clear();
53    }
54
55    pub fn visit(&mut self, path: String) -> bool {
56        self.visited.insert(path)
57    }
58
59    pub fn is_visited(&self, path: &str) -> bool {
60        self.visited.contains(path)
61    }
62
63    pub fn is_option(&self, option: Options) -> bool {
64        self.options.contains(option)
65    }
66}
67
68impl Context<'_, ()> {
69    pub fn new<T>(spec: &T, options: EnumSet<Options>) -> Context<'_, T> {
70        Context {
71            spec,
72            visited: HashSet::new(),
73            errors: Vec::new(),
74            options,
75        }
76    }
77}
78
79impl<'a, T> From<Context<'a, T>> for Result<(), Error> {
80    fn from(val: Context<'a, T>) -> Self {
81        if val.errors.is_empty() {
82            Ok(())
83        } else {
84            Err(Error { errors: val.errors })
85        }
86    }
87}
88
89/// Validates that the given optional email string contains an '@' character.
90/// If the email is present and invalid, records an error in the context.
91pub fn validate_email<T>(email: &Option<String>, ctx: &mut Context<T>, path: String) {
92    if let Some(email) = email
93        && !email.contains('@')
94    {
95        ctx.error(
96            path,
97            format_args!("must be a valid email address, found `{email}`"),
98        );
99    }
100}
101
102const HTTP: &str = "http://";
103const HTTPS: &str = "https://";
104
105/// Validates an optional URL string.
106/// If the URL is present, it checks if it is valid using `validate_required_url`.
107/// Records an error in the context if the URL is invalid.
108pub fn validate_optional_url<T>(url: &Option<String>, ctx: &mut Context<T>, path: String) {
109    if let Some(url) = url {
110        validate_required_url(url, ctx, path);
111    }
112}
113
114/// Validates that the given URL string starts with "http://" or "https://".
115/// If the URL is invalid, records an error in the context.
116pub fn validate_required_url<T>(url: &String, ctx: &mut Context<T>, path: String) {
117    if !ctx.is_option(Options::IgnoreEmptyExternalDocumentationUrl) {
118        validate_required_string(url, ctx, path.clone());
119    }
120    // If the URL is empty or the ignore option is set, skip validation.
121    if url.is_empty() || ctx.is_option(Options::IgnoreInvalidUrls) {
122        return;
123    }
124
125    // TODO: Consider using a more robust URL validation library.
126    if !url.starts_with(HTTP) && !url.starts_with(HTTPS) {
127        ctx.error(path, format_args!("must be a valid URL, found `{url}`"));
128    }
129}
130
131/// Validates that the given string is not empty.
132/// If the string is empty, records an error in the context.
133pub fn validate_required_string<T>(s: &str, ctx: &mut Context<T>, path: String) {
134    if s.is_empty() {
135        ctx.error(path, "must not be empty");
136    }
137}
138
139/// Validates that the given string matches the provided regex pattern.
140/// If the string does not match, records an error in the context with details.
141pub fn validate_string_matches<T>(s: &str, pattern: &Regex, ctx: &mut Context<T>, path: String) {
142    if !pattern.is_match(s) {
143        ctx.error(
144            path,
145            format_args!("must match pattern `{pattern}`, found `{s}`"),
146        );
147    }
148}
149
150// Validates an optional string against a regex pattern if present.
151pub fn validate_optional_string_matches<T>(
152    s: &Option<String>,
153    pattern: &Regex,
154    ctx: &mut Context<T>,
155    path: String,
156) {
157    if let Some(s) = s {
158        validate_string_matches(s, pattern, ctx, path);
159    }
160}
161
162/// Validates that the given regex pattern is valid.
163/// If the pattern is invalid, records an error in the context with details.
164pub fn validate_pattern<T>(pattern: &str, ctx: &mut Context<T>, path: String) {
165    match Regex::new(pattern) {
166        Ok(_) => {}
167        Err(e) => ctx.error(path, format_args!("pattern `{pattern}` is invalid: {e}")),
168    }
169}
170
171/// Validates that the given object has not been visited before,
172/// optionally ignoring the check based on the provided option.
173/// If the object has already been visited and the ignore option is not set, an error is recorded.
174/// Then, the object's own validation logic is invoked.
175pub fn validate_not_visited<T, D>(
176    obj: &D,
177    ctx: &mut Context<T>,
178    ignore_option: Options,
179    path: String,
180) where
181    D: ValidateWithContext<T>,
182{
183    if ctx.visit(path.clone()) {
184        if !ctx.is_option(ignore_option) {
185            ctx.error(path.clone(), "unused");
186        }
187        obj.validate_with_context(ctx, path);
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_validate_url() {
197        let mut ctx = Context::new(&(), Options::new());
198        validate_required_url(
199            &String::from("http://example.com"),
200            &mut ctx,
201            String::from("test_url"),
202        );
203        assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
204
205        let mut ctx = Context::new(&(), Options::new());
206        validate_required_url(
207            &String::from("https://example.com"),
208            &mut ctx,
209            String::from("test_url"),
210        );
211        assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
212
213        let mut ctx = Context::new(&(), Options::new());
214        validate_required_url(&String::from("foo-bar"), &mut ctx, String::from("test_url"));
215        assert!(
216            ctx.errors
217                .contains(&"test_url: must be a valid URL, found `foo-bar`".to_string()),
218            "expected error: {:?}",
219            ctx.errors
220        );
221
222        let mut ctx = Context::new(&(), Options::only(&Options::IgnoreInvalidUrls));
223        validate_required_url(&String::from("foo-bar"), &mut ctx, String::from("test_url"));
224        assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
225
226        let mut ctx = Context::new(&(), Options::new());
227        validate_optional_url(&None, &mut ctx, String::from("test_url"));
228        assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
229
230        let mut ctx = Context::new(&(), Options::new());
231        validate_optional_url(
232            &Some(String::from("http://example.com")),
233            &mut ctx,
234            String::from("test_url"),
235        );
236        assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
237
238        let mut ctx = Context::new(&(), Options::new());
239        validate_optional_url(
240            &Some(String::from("https://example.com")),
241            &mut ctx,
242            String::from("test_url"),
243        );
244        assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
245
246        let mut ctx = Context::new(&(), Options::new());
247        validate_optional_url(
248            &Some(String::from("foo-bar")),
249            &mut ctx,
250            String::from("test_url"),
251        );
252        assert!(
253            ctx.errors
254                .contains(&"test_url: must be a valid URL, found `foo-bar`".to_string()),
255            "expected error: {:?}",
256            ctx.errors
257        );
258
259        let mut ctx = Context::new(&(), Options::only(&Options::IgnoreInvalidUrls));
260        validate_optional_url(
261            &Some(String::from("foo-bar")),
262            &mut ctx,
263            String::from("test_url"),
264        );
265        assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
266    }
267}