Skip to main content

acorn/schema/
validate.rs

1//! # Schema validation helpers
2//!
3//! Generic validation functions and custom [validator](https://docs.rs/validator/latest/validator/) functions for validating schema data
4//!
5#[cfg(feature = "std")]
6use crate::io::database::{schema::LicenseRow, Row};
7#[cfg(feature = "std")]
8use crate::io::License;
9#[cfg(feature = "std")]
10use crate::prelude::env;
11use crate::prelude::*;
12use crate::schema::geonames::{CodeFormat, GeonamesParser};
13use crate::schema::pid::{Patent, PersistentIdentifierConvert};
14use crate::schema::standard::datacite::GeoLocationPoint;
15#[cfg(feature = "std")]
16use crate::util::constants::env::DATABASE_PATH;
17use crate::util::constants::{RE_FAKE_PHONE, RE_IMAGE_EXTENSION, RE_IP6, RE_ISO_8601_DATE, RE_PARTIAL_DATE, RE_PHONE, RE_RAID, RE_UNIX_EPOCH};
18use crate::util::Constant;
19#[cfg(not(feature = "std"))]
20use crate::util::License;
21use crate::util::SemanticVersion;
22use crate::{list_validator, method_validator, regex_validator};
23#[cfg(feature = "std")]
24use chrono::SecondsFormat;
25#[cfg(feature = "std")]
26use chrono::Utc;
27use chrono::{DateTime, Datelike, NaiveDate};
28use convert_case::{Case, Casing};
29use core::iter::once;
30use fluent_uri::UriRef;
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use validator::{Validate, ValidationError, ValidationErrors};
34
35method_validator!(is_ark, "ARK");
36method_validator!(is_doi, "DOI");
37method_validator!(is_isbn, "ISBN");
38method_validator!(is_orcid, "ORCiD");
39method_validator!(is_ror, "ROR");
40regex_validator!(is_ip6, RE_IP6, "IP6");
41regex_validator!(is_raid, RE_RAID, "RAiD");
42regex_validator!(
43    has_image_extension,
44    RE_IMAGE_EXTENSION,
45    "image",
46    "Provide path with a PNG, JPEG, GIF, WEBP, TIFF or SVG extension"
47);
48regex_validator!(is_date, RE_ISO_8601_DATE, "date", "Provide valid ISO 8601 date (e.g., YYYY-MM-DD)");
49regex_validator!(
50    is_partial_date,
51    RE_PARTIAL_DATE,
52    "date",
53    "Provide valid date (e.g., YYYY-MM-DD or YYYY-MM)"
54);
55list_validator!(
56    /// Custom validator function for validating list of state abbreviations
57    is_states,
58    is_state,
59    "state",
60    "Every state should be valid"
61);
62list_validator!(
63    /// Custom validator function for validating list of URLs
64    is_urls,
65    is_url,
66    "url",
67    "Every URL should be valid"
68);
69/// Integer-or-string value used by selected schema fields.
70#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
71#[serde(untagged)]
72pub enum IntegerOrString {
73    /// Integer representation.
74    Number(i64),
75    /// String representation.
76    Text(String),
77}
78
79/// Month value represented as integer or string.
80#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
81#[serde(untagged)]
82pub enum MonthValue {
83    /// Integer representation in range 1-12.
84    Number(u8),
85    /// String representation such as "1" through "12".
86    Text(String),
87}
88
89/// Number-or-string value used by selected schema fields.
90#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
91#[serde(untagged)]
92pub enum NumberOrString {
93    /// Numeric representation.
94    Number(f64),
95    /// String representation.
96    Text(String),
97}
98/// Year value represented as text or integer.
99#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
100#[serde(untagged)]
101pub enum YearValue {
102    /// Numeric year representation.
103    Number(i64),
104    /// String year representation.
105    Text(String),
106}
107
108/// Postal code represented as either text or integer.
109#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
110#[serde(untagged)]
111pub enum PostalCode {
112    /// Numeric postal code.
113    Number(i64),
114    /// String postal code.
115    Text(String),
116}
117/// Format a phone number into a standard format
118/// ### Note
119/// > Output format is `000.000.0000`
120///
121/// ### Example
122/// ```rust
123/// use acorn::schema::validate::format_phone_number;
124///
125/// assert_eq!(format_phone_number("(123) 456-7890"), Ok("123.456.7890".to_string()));
126/// ```
127pub fn format_phone_number(value: &str) -> Result<String, ValidationError> {
128    const CODE: &str = "phone";
129    const MESSAGE: &str = "Unable to format telephone number";
130    match RE_PHONE.captures(value) {
131        | Ok(value) => match value {
132            | Some(captures) => {
133                let country_code = match captures.name("country") {
134                    | Some(value) => Some(value.as_str().trim().to_string()),
135                    | None => None,
136                };
137                let area_code = match captures.name("area") {
138                    | Some(value) => Some(value.as_str().replace("(", "").replace(")", "")),
139                    | None => None,
140                };
141                let prefix = match captures.name("prefix") {
142                    | Some(value) => Some(value.as_str().to_string()),
143                    | None => None,
144                };
145                let line = match captures.name("line") {
146                    | Some(value) => Some(value.as_str().to_string()),
147                    | None => None,
148                };
149                Ok([country_code, area_code, prefix, line]
150                    .into_iter()
151                    .flatten()
152                    .collect::<Vec<String>>()
153                    .join("."))
154            }
155            | None => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
156        },
157        | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
158    }
159}
160/// Normalizes RFC3339 timestamps to microsecond precision for display.
161///
162/// This function parses an RFC3339 timestamp string and reformats it to have
163/// microsecond precision. If parsing fails, it returns the original string.
164///
165/// # Arguments
166///
167/// * `value` - A string slice containing an RFC3339 timestamp
168///
169/// # Returns
170///
171/// A String containing the normalized timestamp with microsecond precision,
172/// or the original string if parsing fails.
173///
174/// # Examples
175///
176/// ```
177/// use acorn::schema::validate::format_timestamp;
178///
179/// // Valid RFC3339 timestamp with nanoseconds gets normalized to microseconds
180/// let input = "2023-01-01T12:00:00.123456789Z";
181/// let expected = "2023-01-01T12:00:00.123456Z";
182/// assert_eq!(format_timestamp(input), expected);
183///
184/// // Already in microsecond precision stays the same
185/// let input = "2023-01-01T12:00:00.123456Z";
186/// assert_eq!(format_timestamp(input), input);
187///
188/// // Invalid timestamp returns original string
189/// let invalid = "invalid-timestamp";
190/// assert_eq!(format_timestamp(invalid), invalid);
191/// ```
192pub fn format_timestamp(value: &str) -> String {
193    #[cfg(feature = "std")]
194    {
195        DateTime::parse_from_rfc3339(value)
196            .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Micros, false))
197            .unwrap_or_else(|_| value.to_string())
198    }
199    #[cfg(not(feature = "std"))]
200    {
201        value.to_string()
202    }
203}
204/// Validate that at least one item in `values` satisfies `method`
205///
206/// This helper is intended for composing `validator` custom functions.
207pub fn has_at_least_one_truthy<T>(values: &[T], method: fn(&T) -> bool, code: &'static str, message: &'static str) -> Result<(), ValidationError> {
208    match values.iter().any(method) {
209        | true => Ok(()),
210        | false => Err(ValidationError::new(code).with_message(message.into())),
211    }
212}
213/// Check if value is a valid commit hash
214pub fn is_commit(value: &str) -> Result<(), ValidationError> {
215    const CODE: &str = "commit";
216    const MESSAGE: &str = "Provide valid commit hash (7-64 hexadecimal characters)";
217    let valid = (7..=64).contains(&value.len()) && value.chars().all(|character| character.is_ascii_hexdigit());
218    match valid {
219        | true => Ok(()),
220        | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
221    }
222}
223/// Check if value is a valid ISO 3166 country code
224///
225/// ISO 3166 is the International Standard for country codes and codes for their subdivisions
226///
227/// See <https://www.iso.org/iso-3166-country-codes.html> for more information
228pub fn is_country_code(value: impl ToString) -> Result<(), ValidationError> {
229    const CODE: &str = "country_code";
230    const MESSAGE: &str = "Provide valid ISO 3166-1 alpha-2 country code";
231    let x = value.to_string();
232    let valid = x.len() == 2 && Constant::country_codes(CodeFormat::Alpha2).contains(&x.to_lowercase());
233    match valid {
234        | true => Ok(()),
235        | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
236    }
237}
238/// Check if value is a valid ISO 639 language code (two-letter code with optional region subtag, e.g., "en" or "en-US")
239///
240/// ISO 639 is the internationally recognized code for the representation of the world's languages and language groups
241///
242/// See <https://www.iso.org/iso-639-language-code> for more information
243pub fn is_iso_639_language_code(value: &str) -> Result<(), ValidationError> {
244    const CODE: &str = "language";
245    const MESSAGE: &str = "Provide valid ISO 639 language code";
246    let candidate = value.to_lowercase();
247    let valid = Constant::languages(CodeFormat::Alpha2).contains(&candidate) || Constant::languages(CodeFormat::Alpha3).contains(&candidate);
248    match valid {
249        | true => Ok(()),
250        | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
251    }
252}
253/// Check if value is a valid ISO 639-1 language code (two-letter code only, e.g., 'en', 'fr', etc.)
254///
255/// ISO 639 is the internationally recognized code for the representation of the world's languages and language groups
256///
257/// See <https://www.iso.org/iso-639-language-code> for more information
258pub fn is_iso_639_1_language_code(value: &str) -> Result<(), ValidationError> {
259    const CODE: &str = "language";
260    const MESSAGE: &str = "Provide valid ISO 639-1 language code (two-letter code only)";
261    let valid = value.len() == 2 && Constant::languages(CodeFormat::Alpha2).contains(&value.to_lowercase());
262    match valid {
263        | true => Ok(()),
264        | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
265    }
266}
267/// Check if value is a valid kebab-case (e.g. 'this-is-kebab-case')
268pub fn is_kebabcase(value: &str) -> Result<(), ValidationError> {
269    const CODE: &str = "kebabcase";
270    let valid = value.to_case(Case::Kebab);
271    let message = format!("Provide ID in kebab-case format (e.g., {valid})");
272    match valid.eq(&value) {
273        | true => Ok(()),
274        | _ => Err(ValidationError::new(CODE).with_message(message.into())),
275    }
276}
277/// Check if value is a valid latitude
278pub fn is_latitude(value: impl ToString) -> Result<(), ValidationError> {
279    const CODE: &str = "latitude";
280    const MESSAGE: &str = "Provide valid latitude (-90 to 90)";
281    let parsed = value.to_string().parse::<f64>().ok();
282    match parsed.filter(|x| (-90.0..=90.0).contains(x)) {
283        | Some(_) => Ok(()),
284        | None => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
285    }
286}
287/// Check if value is a valid license
288/// ### Note
289/// Value is validated against local database
290#[cfg(feature = "std")]
291pub fn is_license(value: &str) -> Result<(), ValidationError> {
292    const CODE: &str = "license";
293    const MESSAGE: &str = "Provide valid SPDX license identifier";
294    let candidate = value.trim().to_string();
295    let normalized = candidate.to_ascii_lowercase();
296    let validation_error = |message: String| {
297        let mut why = ValidationError::new(CODE).with_message(message.into());
298        why.add_param("value".into(), &candidate);
299        why
300    };
301    let predicate = |row: &LicenseRow| {
302        let LicenseRow { identifier, name, .. } = row;
303        let is_valid_identifier = identifier.as_ref().map(|id| id.eq_ignore_ascii_case(&normalized)).unwrap_or(false);
304        let is_valid_name = name.as_ref().map(|name| name.eq_ignore_ascii_case(&normalized)).unwrap_or(false);
305        is_valid_identifier || is_valid_name
306    };
307    if normalized.is_empty() {
308        Err(validation_error(format!("{MESSAGE} (cannot be empty)")))
309    } else {
310        match env::var(DATABASE_PATH) {
311            | Ok(path) => {
312                let exists = LicenseRow::default().select(Some(path.into()), predicate).map(|row| row.is_some());
313                match exists {
314                    | Ok(true) => Ok(()),
315                    | Ok(false) => Err(validation_error(MESSAGE.to_string())),
316                    | Err(_) => Err(validation_error("Failed to access local database for license validation".to_string())),
317                }
318            }
319            | Err(_) => Err(validation_error("Failed to access local database for license validation".to_string())),
320        }
321    }
322}
323#[cfg(not(feature = "std"))]
324/// Check if value is a non-empty SPDX license identifier string in no-std mode.
325pub fn is_license(value: &str) -> Result<(), ValidationError> {
326    const CODE: &str = "license";
327    const MESSAGE: &str = "Provide valid SPDX license identifier";
328    let value = value.trim();
329    if value.is_empty() {
330        Err(ValidationError::new(CODE).with_message(format!("{MESSAGE} (cannot be empty)").into()))
331    } else {
332        Ok(())
333    }
334}
335/// Check if value is a valid longitude
336pub fn is_longitude<T>(value: T) -> Result<(), ValidationError>
337where
338    T: ToString,
339{
340    const CODE: &str = "longitude";
341    const MESSAGE: &str = "Provide valid longitude (-180 to 180)";
342    let parsed = value.to_string().parse::<f64>().ok();
343    match parsed.filter(|x| (-180.0..=180.0).contains(x)) {
344        | Some(_) => Ok(()),
345        | None => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
346    }
347}
348/// Check if value is a valid [`crate::schema::pid::Patent`]
349pub fn is_patent_identifier(value: &str) -> Result<(), ValidationError> {
350    const CODE: &str = "patent";
351    const MESSAGE: &str = "Provide valid patent identifier";
352    match Patent::is_valid(value) {
353        | true => Ok(()),
354        | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
355    }
356}
357/// Check if value is a valid phone number
358///
359/// Uses same regex as `format_phone_number`
360pub fn is_phone_number(value: &str) -> Result<(), ValidationError> {
361    const CODE: &str = "phone";
362    let is_fake = RE_FAKE_PHONE.is_match(value).unwrap_or(false);
363    let is_valid = RE_PHONE.is_match(value).unwrap_or(false);
364    match (is_valid, is_fake) {
365        | (true, false) => Ok(()),
366        | (_, true) => Err(ValidationError::new(CODE).with_message("Provide real phone number, not a placeholder".into())),
367        | _ => Err(ValidationError::new(CODE).with_message("Provide valid phone number".into())),
368    }
369}
370/// Check if list of locations depict a valid polygon
371pub fn is_polygon(value: &[GeoLocationPoint]) -> Result<(), ValidationError> {
372    const CODE: &str = "polygon";
373    const EMPTY_MESSAGE: &str = "Provide valid polygon — no points were provided";
374    let length = value.len();
375    let result = if length == 0 {
376        Err(ValidationError::new(CODE).with_message(EMPTY_MESSAGE.into()))
377    } else if length < 4 {
378        let length_message = format!("Provide valid polygon — at least 4 points are required (only {length} found)");
379        Err(ValidationError::new(CODE).with_message(length_message.into()))
380    } else {
381        match (value.first(), value.last()) {
382            | (Some(first), Some(last)) if first == last => Ok(()),
383            | (Some(first), Some(last)) => {
384                let closed_message = format!("Provide valid polygon — first point must equal last point ({first} ≠ {last})");
385                Err(ValidationError::new(CODE).with_message(closed_message.into()))
386            }
387            | _ => Err(ValidationError::new(CODE).with_message(EMPTY_MESSAGE.into())),
388        }
389    };
390    result
391}
392/// Check if value is a valid RFC3339 timestamp (ISO 8601 compliant)
393/// ### Examples
394/// - `2026-03-12T15:04:05Z`
395/// - `2026-03-12T15:04:05+00:00`
396pub fn is_rfc3339(value: impl ToString) -> Result<(), ValidationError> {
397    const CODE: &str = "date";
398    const MESSAGE: &str = "Provide valid RFC 3339 timestamp (ISO 8601 compliant)";
399    let x = value.to_string();
400    let is_rfc3339 = DateTime::parse_from_rfc3339(&x).is_ok();
401    let is_date = NaiveDate::parse_from_str(&x, "%Y-%m-%d").is_ok();
402    let is_supported_date_value = |value: &str| DateTime::parse_from_rfc3339(value).is_ok() || NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok();
403    let is_date_interval = x
404        .split_once('/')
405        .map(|(start, end)| is_supported_date_value(start) && is_supported_date_value(end))
406        .unwrap_or(false);
407    match is_rfc3339 || is_date || is_date_interval {
408        | true => Ok(()),
409        | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
410    }
411}
412/// Check if value is a valid semantic version (e.g., 1.2.3)
413///
414/// See [`crate::util::SemanticVersion`]
415pub fn is_semantic_version(value: &str) -> Result<(), ValidationError> {
416    const CODE: &str = "version";
417    const MESSAGE: &str = "Provide valid semantic version (e.g., 1.2.3)";
418    let parsed = SemanticVersion::from(value);
419    match parsed.to_string().starts_with(value) {
420        | true => Ok(()),
421        | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
422    }
423}
424/// Check if value is a valid state abbreviation (or Canada)
425pub fn is_state(value: impl ToString) -> Result<(), ValidationError> {
426    const CODE: &str = "state";
427    const MESSAGE: &str = "Provide valid US state or Canadian province abbreviation (e.g., CA, NY, ON)";
428    let x = value.to_string();
429    let valid = x.len() == 2 && x.chars().all(|c| c.is_ascii_alphabetic());
430    match valid {
431        | true => Ok(()),
432        | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
433    }
434}
435/// Check if value is a valid Unix epoch timestamp
436pub fn is_unix_epoch(value: usize) -> Result<(), ValidationError> {
437    const CODE: &str = "epoch";
438    const MESSAGE: &str = "Provide valid Unix epoch timestamp";
439    match RE_UNIX_EPOCH.is_match(&value.to_string()) {
440        | Ok(value) if value => Ok(()),
441        | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
442    }
443}
444/// Check if value is a valid URL
445pub fn is_url(value: impl ToString) -> Result<(), ValidationError> {
446    const CODE: &str = "url";
447    const MESSAGE: &str = "Provide valid URL";
448    let x = value.to_string();
449    match UriRef::parse(x.as_str()) {
450        | Ok(_) => Ok(()),
451        | Err(_) => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
452    }
453}
454/// Check if value is a valid ISO 8601 year (e.g., YYYY)
455/// ### Examples
456/// - `2025`
457pub fn is_year(value: impl ToString) -> Result<(), ValidationError> {
458    const CODE: &str = "year";
459    const MESSAGE: &str = "Provide valid ISO 8601 year (e.g., YYYY)";
460    let x = value.to_string();
461    let parsed_year = (x.len() == 4)
462        .then(|| format!("{x}-01-01"))
463        .and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok())
464        .map(|date| date.year());
465    match parsed_year {
466        | Some(year) if is_viable_year(year) => Ok(()),
467        | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
468    }
469}
470#[cfg(feature = "std")]
471fn is_viable_year(year: i32) -> bool {
472    let now: DateTime<Utc> = Utc::now();
473    year <= now.year()
474}
475#[cfg(not(feature = "std"))]
476fn is_viable_year(_year: i32) -> bool {
477    true
478}
479impl Validate for IntegerOrString {
480    fn validate(&self) -> Result<(), ValidationErrors> {
481        Ok(())
482    }
483}
484impl Validate for License {
485    fn validate(&self) -> Result<(), ValidationErrors> {
486        let to_validation_errors = |why: ValidationError| {
487            once(why).fold(ValidationErrors::new(), |mut acc, err| {
488                acc.add("license", err);
489                acc
490            })
491        };
492        #[cfg(feature = "std")]
493        {
494            let invalid = match self {
495                | License::Single(value) => is_license(value).err(),
496                | License::Multiple(values) => values.iter().find_map(|value| is_license(value).err()),
497            };
498            invalid.map_or(Ok(()), |why| Err(to_validation_errors(why)))
499        }
500        #[cfg(not(feature = "std"))]
501        {
502            is_license(&self.to_string()).map_err(to_validation_errors)
503        }
504    }
505}
506impl Validate for MonthValue {
507    fn validate(&self) -> Result<(), ValidationErrors> {
508        let to_validation_errors = |why: ValidationError| {
509            once(why).fold(ValidationErrors::new(), |mut acc, err| {
510                acc.add("month", err);
511                acc
512            })
513        };
514        let valid = match self {
515            | Self::Number(value) => (1..=12).contains(value),
516            | Self::Text(value) => matches!(value.as_str(), "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12"),
517        };
518        match valid {
519            | true => Ok(()),
520            | false => Err(to_validation_errors(
521                ValidationError::new("range").with_message("Month should be an integer 1-12 or a string value from \"1\" to \"12\"".into()),
522            )),
523        }
524    }
525}
526impl Validate for NumberOrString {
527    fn validate(&self) -> Result<(), ValidationErrors> {
528        Ok(())
529    }
530}
531impl Validate for YearValue {
532    fn validate(&self) -> Result<(), ValidationErrors> {
533        let to_validation_errors = |why: ValidationError| {
534            once(why).fold(ValidationErrors::new(), |mut acc, err| {
535                acc.add("year", err);
536                acc
537            })
538        };
539        match self {
540            | Self::Number(value) => is_year(*value).map_err(to_validation_errors),
541            | Self::Text(value) => is_year(value).map_err(to_validation_errors),
542        }
543    }
544}
545
546impl Validate for PostalCode {
547    fn validate(&self) -> Result<(), ValidationErrors> {
548        let to_validation_errors = |why: ValidationError| {
549            once(why).fold(ValidationErrors::new(), |mut acc, err| {
550                acc.add("postal_code", err);
551                acc
552            })
553        };
554        let valid = match self {
555            | Self::Number(value) => *value >= 0,
556            | Self::Text(value) => {
557                let trimmed = value.trim();
558                !trimmed.is_empty()
559                    && trimmed.len() <= 20
560                    && trimmed
561                        .chars()
562                        .all(|character| character.is_alphanumeric() || character == ' ' || character == '-')
563            }
564        };
565        match valid {
566            | true => Ok(()),
567            | false => Err(to_validation_errors(ValidationError::new("format").with_message(
568                "Postal code should be a non-negative integer or a non-empty string up to 20 characters using letters, digits, spaces, or hyphens"
569                    .into(),
570            ))),
571        }
572    }
573}